<?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>Monitoring on Tarragon</title><link>https://tarrragon.github.io/blog/tags/monitoring/</link><description>Recent content in Monitoring on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Thu, 02 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/monitoring/index.xml" rel="self" type="application/rss+xml"/><item><title>Collector 架構</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/architecture/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/architecture/</guid><description>&lt;p>Collector 是監控資料的接收與處理中心，職責是把 SDK 送來的事件資料轉換成可查詢、可觸發動作的持久化記錄。整條鏈路由五段組成，每段有明確的輸入和輸出，段與段之間用結構化資料傳遞。&lt;/p>
&lt;h2 id="五段處理鏈路">五段處理鏈路&lt;/h2>
&lt;h3 id="第一段http-endpoint-接收">第一段：HTTP endpoint 接收&lt;/h3>
&lt;p>Collector 對外提供一個 HTTP POST endpoint（例如 &lt;code>/v1/events&lt;/code>），接收 SDK 送來的 JSON body。每個 request 可以是單一事件或批次事件陣列。&lt;/p>
&lt;p>Endpoint 的職責只有兩件事：驗證 HTTP 層面的基本條件（Content-Type、body size limit、認證 token），然後把 body 傳給下一段。HTTP 層面的錯誤（413 body too large、401 unauthorized）在這裡回應，不進入後續處理。&lt;/p>
&lt;p>自用工具場景下，Go 的 &lt;code>net/http&lt;/code> 標準庫提供的 HTTP server 已足夠。一個 &lt;code>http.HandleFunc(&amp;quot;/v1/events&amp;quot;, handler)&lt;/code> 加上 &lt;code>json.NewDecoder(r.Body).Decode(&amp;amp;events)&lt;/code> 就完成接收。不需要 framework。&lt;/p>
&lt;h3 id="第二段json-schema-驗證">第二段：JSON Schema 驗證&lt;/h3>
&lt;p>收到的 JSON body 用 JSON Schema 驗證結構正確性 — 必要欄位是否存在、型別是否正確、值是否在合法範圍內。驗證失敗的事件被拒絕並記錄原因，通過的事件進入下一段。&lt;/p>
&lt;p>Schema 驗證是 collector 的品質閘門。沒有驗證的 collector 會累積格式不一致的資料，查詢時需要處理各種邊界條件。驗證在寫入前攔截問題，比寫入後清理成本低。&lt;/p>
&lt;p>驗證的粒度是事件級 — 批次中的一個事件驗證失敗不影響其他事件。回應中標明哪些事件被接受、哪些被拒絕及原因。&lt;/p>
&lt;h3 id="ingestion-回應格式">Ingestion 回應格式&lt;/h3>
&lt;p>回應格式把「接受了幾筆、拒絕了幾筆、拒絕原因」三件事用一套一致的結構表達。SDK 端只需要判斷 status code 就知道怎麼處理 buffer。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 200 OK — 單筆成功或批次全部成功
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;accepted&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1">// 207 Multi-Status — 批次部分失敗
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;accepted&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;rejected&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;errors&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;index&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;message&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;missing required field: type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;fields&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="c1">// 400 Bad Request — 單筆失敗或批次全部失敗
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;schema validation failed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;details&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;field&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;message&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;missing required field&amp;#34;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="c1">// 503 Service Unavailable — 寫入端暫時不可用
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;service temporarily unavailable&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;retry_after&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">5&lt;/span> &lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>設計選擇：207 的 &lt;code>errors&lt;/code> 陣列用 &lt;code>index&lt;/code> 標明失敗事件在原始 batch 中的位置（0-based），SDK 可以用 index 對照原始事件做 debug log。合法事件不因部分失敗而被丟棄 — 部分成功是 batch 收集的核心價值。400 和 207 的差異是「全軍覆沒 vs 部分存活」，SDK 端的處理策略不同：400 直接清 buffer（schema 問題重試也不會過），207 只清成功的部分。&lt;/p>
&lt;h3 id="health-endpoint-回應">Health endpoint 回應&lt;/h3>
&lt;p>Health endpoint 回傳 collector 自身的運行狀態，不包含事件內容。用途是 SDK 端確認 collector 可達、監控腳本定期探測。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// GET /health → 200 OK
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;status&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;uptime_seconds&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">3600&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;total_events&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1234&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;storage_bytes&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">5242880&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;version&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;0.1.0&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>total_events&lt;/code> 和 &lt;code>storage_bytes&lt;/code> 讓監控腳本判斷 collector 的負載趨勢。&lt;code>version&lt;/code> 讓 SDK 確認 collector 版本（schema 不匹配時的第一個 debug 線索）。&lt;/p>
&lt;h3 id="第三段儲存">第三段：儲存&lt;/h3>
&lt;p>通過驗證的事件寫入 Storage Backend。Collector 使用可插拔的 Storage interface — day-one 預設用 SQLite（零依賴、嵌入式），分析需求觸發時切換到 PostgreSQL。具體的 backend 選擇和功能分層見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇&lt;/a>，可插拔架構見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Collector 是監控資料的接收與處理中心，職責是把 SDK 送來的事件資料轉換成可查詢、可觸發動作的持久化記錄。整條鏈路由五段組成，每段有明確的輸入和輸出，段與段之間用結構化資料傳遞。</p>
<h2 id="五段處理鏈路">五段處理鏈路</h2>
<h3 id="第一段http-endpoint-接收">第一段：HTTP endpoint 接收</h3>
<p>Collector 對外提供一個 HTTP POST endpoint（例如 <code>/v1/events</code>），接收 SDK 送來的 JSON body。每個 request 可以是單一事件或批次事件陣列。</p>
<p>Endpoint 的職責只有兩件事：驗證 HTTP 層面的基本條件（Content-Type、body size limit、認證 token），然後把 body 傳給下一段。HTTP 層面的錯誤（413 body too large、401 unauthorized）在這裡回應，不進入後續處理。</p>
<p>自用工具場景下，Go 的 <code>net/http</code> 標準庫提供的 HTTP server 已足夠。一個 <code>http.HandleFunc(&quot;/v1/events&quot;, handler)</code> 加上 <code>json.NewDecoder(r.Body).Decode(&amp;events)</code> 就完成接收。不需要 framework。</p>
<h3 id="第二段json-schema-驗證">第二段：JSON Schema 驗證</h3>
<p>收到的 JSON body 用 JSON Schema 驗證結構正確性 — 必要欄位是否存在、型別是否正確、值是否在合法範圍內。驗證失敗的事件被拒絕並記錄原因，通過的事件進入下一段。</p>
<p>Schema 驗證是 collector 的品質閘門。沒有驗證的 collector 會累積格式不一致的資料，查詢時需要處理各種邊界條件。驗證在寫入前攔截問題，比寫入後清理成本低。</p>
<p>驗證的粒度是事件級 — 批次中的一個事件驗證失敗不影響其他事件。回應中標明哪些事件被接受、哪些被拒絕及原因。</p>
<h3 id="ingestion-回應格式">Ingestion 回應格式</h3>
<p>回應格式把「接受了幾筆、拒絕了幾筆、拒絕原因」三件事用一套一致的結構表達。SDK 端只需要判斷 status code 就知道怎麼處理 buffer。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 200 OK — 單筆成功或批次全部成功
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="p">{</span> <span class="nt">&#34;accepted&#34;</span><span class="p">:</span> <span class="mi">1</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">// 207 Multi-Status — 批次部分失敗
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="nt">&#34;accepted&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="nt">&#34;rejected&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nt">&#34;errors&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">{</span> <span class="nt">&#34;index&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&#34;message&#34;</span><span class="p">:</span> <span class="s2">&#34;missing required field: type&#34;</span><span class="p">,</span> <span class="nt">&#34;fields&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;type&#34;</span><span class="p">]</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <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="c1">// 400 Bad Request — 單筆失敗或批次全部失敗
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="nt">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;schema validation failed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="nt">&#34;details&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">{</span> <span class="nt">&#34;field&#34;</span><span class="p">:</span> <span class="s2">&#34;type&#34;</span><span class="p">,</span> <span class="nt">&#34;message&#34;</span><span class="p">:</span> <span class="s2">&#34;missing required field&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="c1">// 503 Service Unavailable — 寫入端暫時不可用
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="c1"></span><span class="p">{</span> <span class="nt">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;service temporarily unavailable&#34;</span><span class="p">,</span> <span class="nt">&#34;retry_after&#34;</span><span class="p">:</span> <span class="mi">5</span> <span class="p">}</span></span></span></code></pre></div><p>設計選擇：207 的 <code>errors</code> 陣列用 <code>index</code> 標明失敗事件在原始 batch 中的位置（0-based），SDK 可以用 index 對照原始事件做 debug log。合法事件不因部分失敗而被丟棄 — 部分成功是 batch 收集的核心價值。400 和 207 的差異是「全軍覆沒 vs 部分存活」，SDK 端的處理策略不同：400 直接清 buffer（schema 問題重試也不會過），207 只清成功的部分。</p>
<h3 id="health-endpoint-回應">Health endpoint 回應</h3>
<p>Health endpoint 回傳 collector 自身的運行狀態，不包含事件內容。用途是 SDK 端確認 collector 可達、監控腳本定期探測。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// GET /health → 200 OK
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;status&#34;</span><span class="p">:</span> <span class="s2">&#34;ok&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;uptime_seconds&#34;</span><span class="p">:</span> <span class="mi">3600</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nt">&#34;total_events&#34;</span><span class="p">:</span> <span class="mi">1234</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nt">&#34;storage_bytes&#34;</span><span class="p">:</span> <span class="mi">5242880</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="nt">&#34;version&#34;</span><span class="p">:</span> <span class="s2">&#34;0.1.0&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>total_events</code> 和 <code>storage_bytes</code> 讓監控腳本判斷 collector 的負載趨勢。<code>version</code> 讓 SDK 確認 collector 版本（schema 不匹配時的第一個 debug 線索）。</p>
<h3 id="第三段儲存">第三段：儲存</h3>
<p>通過驗證的事件寫入 Storage Backend。Collector 使用可插拔的 Storage interface — day-one 預設用 SQLite（零依賴、嵌入式），分析需求觸發時切換到 PostgreSQL。具體的 backend 選擇和功能分層見 <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a>，可插拔架構見 <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a>。</p>
<h3 id="第四段查詢">第四段：查詢</h3>
<p>儲存的事件透過 CLI 指令或 HTTP 查詢 endpoint 存取。SQLite backend 下用 SQL 查詢；匯出為 JSONL 格式後也可用 <code>grep</code> + <code>jq</code> 做臨時分析。</p>
<p>查詢設計見 <a href="/blog/monitoring/04-collector/query-api/" data-link-title="查詢 API 設計" data-link-desc="CLI grep 友好的 JSONL 結構 &#43; HTTP 查詢 endpoint — 兩種查詢介面各自的適用場景和設計要點">查詢 API 設計</a>。</p>
<h3 id="第五段rule-engine">第五段：Rule engine</h3>
<p>Rule engine 在事件寫入後觸發，檢查事件是否匹配預定義的規則。匹配時執行對應的動作（發通知、寫 summary、觸發 webhook）。</p>
<p>Rule engine 設計見 <a href="/blog/monitoring/04-collector/rule-engine/" data-link-title="Rule engine 設計" data-link-desc="條件 → 動作 → 模板的三段式規則結構 — 讓 collector 從被動儲存變成主動回應">Rule engine 設計</a>。</p>
<h2 id="多獨立-client-併發寫入">多獨立 client 併發寫入</h2>
<p>上述五段鏈路描述的是單一 request 的路徑。實際運行時，多個 SDK 會同時送事件——以下先描述場景，下方<a href="#%e4%b8%a6%e7%99%bc%e5%af%ab%e5%85%a5%e7%ad%96%e7%95%a5">並發寫入策略</a>再詳述 collector 如何處理。</p>
<p>常見部署場景中，多個完全獨立的 SDK 實例同時送事件到同一個 collector——不同 process、不同 app、甚至不同語言的 SDK。這和「一個 app 內的多 thread 併發」不同：每個 SDK 有自己的 buffer 和 HTTP 連線，不共享任何狀態。</p>
<p>SDK 端不需要知道其他 SDK 的存在。每個 SDK 獨立 init、獨立 buffer、獨立 flush、獨立 close。SDK 端的唯一接觸點是 collector 的 HTTP endpoint——併發安全由 storage backend 的併發策略保證（見下方<a href="#%e4%b8%a6%e7%99%bc%e5%af%ab%e5%85%a5%e7%ad%96%e7%95%a5">並發寫入策略</a>），不需要 SDK 端協調。多 client 同時 flush 時的背壓機制見 <a href="/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion 背壓與流量管控</a>。</p>
<p>例如 CI pipeline 的多個 job 同時送 build 事件，或微服務架構中多個 service 各自送事件到同一個 collector。另一個具體案例是 Claude Code 的 Hook 系統——多個 Hook 同時觸發時，每個 Hook 是獨立的 Python process，各自初始化 SDK、產生事件、flush 到同一個 collector。</p>
<h2 id="並發寫入策略">並發寫入策略</h2>
<p>Go 的 HTTP server 為每個 request 分配一個 goroutine。多個 SDK 同時 flush 時，collector 同時收到多個寫入請求。Storage Backend 的並發能力決定了這些 goroutine 怎麼協調。</p>
<h3 id="sqlite-backend單寫者模型">SQLite Backend：單寫者模型</h3>
<p>SQLite 的 WAL mode 允許一個 writer 和多個 concurrent reader — 讀寫不互相阻塞，但多個 writer 之間是序列化的。Go 端有兩種處理 pattern：</p>
<p><strong>Single-writer goroutine + channel</strong>：所有 <code>Store()</code> 呼叫把事件送進一個 Go channel，由一個專屬的 goroutine 從 channel 讀取並序列寫入 SQLite。HTTP handler 送完 channel 後等待確認（或用 buffered channel 異步）。優點是背壓控制清晰 — channel 滿時 HTTP handler 自然阻塞，可以回 503。缺點是多一層間接。</p>
<p><strong>Busy timeout fallback</strong>：不在 Go 層管序列化，讓 SQLite driver 自己處理。設定 <code>_pragma=busy_timeout(5000)</code>，多個 goroutine 同時呼叫 <code>Store()</code> 時，SQLite 讓等待的 goroutine block 直到寫入鎖釋放（最多 5 秒）。優點是實作簡單（不需要 channel 和額外 goroutine）。缺點是背壓不可控 — goroutine 數量可能累積。</p>
<p>自用工具場景推薦 busy timeout（簡單）、寫入量增長到出現超時錯誤時切換到 channel pattern。</p>
<h3 id="postgresql-backend連線池">PostgreSQL Backend：連線池</h3>
<p>PostgreSQL 透過連線池（<code>database/sql</code> 的 <code>SetMaxOpenConns</code>）支援並行寫入。多個 goroutine 可以同時寫入不同的連線，不需要額外的序列化機制。</p>
<h2 id="go-單一-binary-的設計選擇">Go 單一 binary 的設計選擇</h2>
<p>Collector 用 Go 編譯成單一 binary，不依賴外部 runtime（JVM、Python interpreter、Node.js）。部署是複製一個檔案，啟動是執行一個指令。</p>
<p>這個選擇在自用工具場景下有特定優勢：server 和 collector 在同一台機器上，部署流程是 <code>scp collector user@host:</code> + <code>ssh user@host ./collector</code>。不需要 package manager、不需要 container registry、不需要 orchestration。</p>
<p>Go 的 <code>net/http</code> 標準庫提供 production-ready 的 HTTP server，JSON 處理用標準庫的 <code>encoding/json</code>，SQLite 用 <code>modernc.org/sqlite</code>（pure Go、無 CGO 依賴）。整個 collector 的核心邏輯可以在 500 行以內完成。</p>
<p>具體的部署步驟（systemd service 檔案、啟動參數、設定檔格式）和 Quick Start（從零到第一筆事件出現在 collector）見 monitor repo 的 deployment guide。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>功能分層與 Backend 選擇 → <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a></li>
<li>可插拔 Storage Backend 架構 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a></li>
<li>JSONL 匯出與備份格式 → <a href="/blog/monitoring/04-collector/jsonl-storage/" data-link-title="JSONL 匯出與備份格式" data-link-desc="JSONL 作為匯出和備份格式的設計 — 人類可讀、grep 友好、SQLite 損壞時的重建來源">JSONL 儲存設計</a></li>
<li>查詢 API 的設計 → <a href="/blog/monitoring/04-collector/query-api/" data-link-title="查詢 API 設計" data-link-desc="CLI grep 友好的 JSONL 結構 &#43; HTTP 查詢 endpoint — 兩種查詢介面各自的適用場景和設計要點">查詢 API 設計</a></li>
<li>Rule engine → <a href="/blog/monitoring/04-collector/rule-engine/" data-link-title="Rule engine 設計" data-link-desc="條件 → 動作 → 模板的三段式規則結構 — 讓 collector 從被動儲存變成主動回應">Rule engine 設計</a></li>
<li>背壓與流量管控的基礎概念 → <a href="/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">DevOps 流量管控</a></li>
<li>端到端資料完整性 → <a href="/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性</a></li>
<li>Error fingerprint 與去重分群 → <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></li>
</ul>
]]></content:encoded></item><item><title>event.schema.json 完整欄位解說</title><link>https://tarrragon.github.io/blog/monitoring/02-log-schema/event-schema-fields/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/02-log-schema/event-schema-fields/</guid><description>&lt;p>事件 schema 定義了每一筆監控事件的資料結構。統一的 schema 讓 SDK、collector、查詢工具使用同一個資料契約 — SDK 知道該送什麼欄位，collector 知道該驗證什麼，查詢工具知道該讀什麼。&lt;/p>
&lt;h2 id="核心欄位">核心欄位&lt;/h2>
&lt;h3 id="type必填">type（必填）&lt;/h3>
&lt;p>事件類型。對應四類事件分類（&lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/four-event-types/" data-link-title="四類事件的完整定義" data-link-desc="Event / Error / Metric / Lifecycle 四類事件各自的語意、觸發時機和典型用途 — 分類是監控體系的統一語言">模組一&lt;/a>）：&lt;code>event&lt;/code>、&lt;code>error&lt;/code>、&lt;code>metric&lt;/code>、&lt;code>lifecycle&lt;/code>。&lt;/p>
&lt;p>Collector 用 type 決定事件的處理路徑 — error 類型觸發告警規則，metric 類型進入數值聚合，event 類型進入行為分析。&lt;/p>
&lt;h3 id="name必填">name（必填）&lt;/h3>
&lt;p>事件名稱。使用 namespace.action 格式（&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;code>terminal.connect.done&lt;/code>、&lt;code>auth.biometric.failed&lt;/code>。&lt;/p>
&lt;p>name 是查詢和統計的主要索引。&lt;code>grep &amp;quot;terminal.connect&amp;quot;&lt;/code> 找到所有連線事件；按 name 分群計數得到功能使用頻率。&lt;/p>
&lt;h3 id="timestamp必填">timestamp（必填）&lt;/h3>
&lt;p>事件發生的時間。ISO 8601 格式，包含時區偏移。&lt;code>2026-06-19T14:30:00.123+08:00&lt;/code>。&lt;/p>
&lt;p>Timestamp 由 SDK 在事件發生時記錄，不是 collector 收到時記錄。兩者可能有延遲（離線 buffer、網路延遲），以 SDK 端的時間為準。&lt;/p>
&lt;h3 id="source必填">source（必填）&lt;/h3>
&lt;p>事件來源的識別資訊。包含產生事件的 SDK、app 名稱、版本、平台、OS 版本。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;source&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;sdk&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;flutter&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;app&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;app_tunnel&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;version&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;1.2.0&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;platform&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ios&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;os&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;17.4&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>sdk&lt;/code> 標明產生事件的 SDK 種類（&lt;code>js&lt;/code> / &lt;code>flutter&lt;/code> / &lt;code>python&lt;/code> / &lt;code>go&lt;/code>）。同一個平台可能有不同的 SDK——iOS 上可能是 Flutter SDK 或未來的 Swift 原生 SDK——sdk 欄位讓 collector 區分事件來自哪個 SDK 實作，platform 無法替代這個識別。&lt;code>sdk&lt;/code> 和 &lt;code>platform&lt;/code> 為必填，&lt;code>app&lt;/code>、&lt;code>version&lt;/code>、&lt;code>os&lt;/code> 為選填。&lt;/p>
&lt;p>Source 讓同一個 collector 接收多個 app 的事件時可以區分來源。也用於分析「哪個版本的 error 率最高」、「哪個 OS 版本有特定問題」。&lt;/p>
&lt;h4 id="platform-合法值與自動偵測">platform 合法值與自動偵測&lt;/h4>
&lt;p>&lt;code>platform&lt;/code> 由 SDK init 時自動偵測，開發者不需手動設定。各 SDK 的偵測來源和映射規則：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>SDK&lt;/th>
 &lt;th>偵測來源&lt;/th>
 &lt;th>映射規則&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Python&lt;/td>
 &lt;td>&lt;code>sys.platform&lt;/code>&lt;/td>
 &lt;td>&lt;code>darwin&lt;/code>→&lt;code>macos&lt;/code>、&lt;code>linux&lt;/code>→&lt;code>linux&lt;/code>、&lt;code>win32&lt;/code>→&lt;code>windows&lt;/code>、其他直接傳原值&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Flutter&lt;/td>
 &lt;td>&lt;code>Platform.operatingSystem&lt;/code>&lt;/td>
 &lt;td>回傳值（&lt;code>ios&lt;/code>/&lt;code>android&lt;/code>/&lt;code>macos&lt;/code>/&lt;code>linux&lt;/code>/&lt;code>windows&lt;/code>）即合法值，無需映射&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>JS&lt;/td>
 &lt;td>瀏覽器環境&lt;/td>
 &lt;td>固定為 &lt;code>web&lt;/code>；OS 偵測（如需要）從 &lt;code>navigator.userAgentData&lt;/code> 解析&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Go&lt;/td>
 &lt;td>&lt;code>runtime.GOOS&lt;/code>&lt;/td>
 &lt;td>&lt;code>darwin&lt;/code>→&lt;code>macos&lt;/code>、&lt;code>linux&lt;/code>→&lt;code>linux&lt;/code>、&lt;code>windows&lt;/code>→&lt;code>windows&lt;/code>、映射邏輯同 Python&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>以上映射是 SDK init 時的預設自動偵測行為。Python 和 Go 的 runtime 回傳系統內部名稱（&lt;code>darwin&lt;/code>、&lt;code>win32&lt;/code>），SDK 負責映射到 schema 定義的標準名稱。Flutter 的 &lt;code>dart:io Platform.operatingSystem&lt;/code> 恰好回傳合法值。JS SDK 在瀏覽器環境中無法可靠偵測 OS，platform 統一為 &lt;code>web&lt;/code>。&lt;/p>
&lt;p>自動偵測之外，SDK 也接受手動覆蓋 platform 值。短生命週期的命令列腳本（如 CI pipeline step、pre-commit hook）可手動將 platform 設為 &lt;code>script&lt;/code>，表示非互動式 OS session——這類場景中 OS 名稱不是有意義的區分維度，&lt;code>script&lt;/code> 讓查詢時能篩選出所有腳本來源的事件。&lt;/p>
&lt;p>SDK 不做映射的話，collector 會收到不一致的 platform 值——同是 macOS 的事件有些標 &lt;code>darwin&lt;/code> 有些標 &lt;code>macos&lt;/code>，查詢篩選會漏事件。各平台 SDK 的執行環境適配細節見&lt;a href="https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/" data-link-title="模組五：平台適配" data-link-desc="JS CORS / Flutter isolate / Python GIL / Go graceful shutdown — 各平台的特殊考量">模組五：平台適配&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>事件 schema 定義了每一筆監控事件的資料結構。統一的 schema 讓 SDK、collector、查詢工具使用同一個資料契約 — SDK 知道該送什麼欄位，collector 知道該驗證什麼，查詢工具知道該讀什麼。</p>
<h2 id="核心欄位">核心欄位</h2>
<h3 id="type必填">type（必填）</h3>
<p>事件類型。對應四類事件分類（<a href="/blog/monitoring/01-mental-model/four-event-types/" data-link-title="四類事件的完整定義" data-link-desc="Event / Error / Metric / Lifecycle 四類事件各自的語意、觸發時機和典型用途 — 分類是監控體系的統一語言">模組一</a>）：<code>event</code>、<code>error</code>、<code>metric</code>、<code>lifecycle</code>。</p>
<p>Collector 用 type 決定事件的處理路徑 — error 類型觸發告警規則，metric 類型進入數值聚合，event 類型進入行為分析。</p>
<h3 id="name必填">name（必填）</h3>
<p>事件名稱。使用 namespace.action 格式（<a href="/blog/monitoring/01-mental-model/event-naming-convention/" data-link-title="事件命名規範" data-link-desc="namespace.action 格式的事件命名、命名一致性的工程價值、和商業方案命名慣例的對應">事件命名規範</a>）。例如 <code>terminal.connect.done</code>、<code>auth.biometric.failed</code>。</p>
<p>name 是查詢和統計的主要索引。<code>grep &quot;terminal.connect&quot;</code> 找到所有連線事件；按 name 分群計數得到功能使用頻率。</p>
<h3 id="timestamp必填">timestamp（必填）</h3>
<p>事件發生的時間。ISO 8601 格式，包含時區偏移。<code>2026-06-19T14:30:00.123+08:00</code>。</p>
<p>Timestamp 由 SDK 在事件發生時記錄，不是 collector 收到時記錄。兩者可能有延遲（離線 buffer、網路延遲），以 SDK 端的時間為準。</p>
<h3 id="source必填">source（必填）</h3>
<p>事件來源的識別資訊。包含產生事件的 SDK、app 名稱、版本、平台、OS 版本。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;source&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nt">&#34;sdk&#34;</span><span class="p">:</span> <span class="s2">&#34;flutter&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nt">&#34;app&#34;</span><span class="p">:</span> <span class="s2">&#34;app_tunnel&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nt">&#34;version&#34;</span><span class="p">:</span> <span class="s2">&#34;1.2.0&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nt">&#34;platform&#34;</span><span class="p">:</span> <span class="s2">&#34;ios&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nt">&#34;os&#34;</span><span class="p">:</span> <span class="s2">&#34;17.4&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>sdk</code> 標明產生事件的 SDK 種類（<code>js</code> / <code>flutter</code> / <code>python</code> / <code>go</code>）。同一個平台可能有不同的 SDK——iOS 上可能是 Flutter SDK 或未來的 Swift 原生 SDK——sdk 欄位讓 collector 區分事件來自哪個 SDK 實作，platform 無法替代這個識別。<code>sdk</code> 和 <code>platform</code> 為必填，<code>app</code>、<code>version</code>、<code>os</code> 為選填。</p>
<p>Source 讓同一個 collector 接收多個 app 的事件時可以區分來源。也用於分析「哪個版本的 error 率最高」、「哪個 OS 版本有特定問題」。</p>
<h4 id="platform-合法值與自動偵測">platform 合法值與自動偵測</h4>
<p><code>platform</code> 由 SDK init 時自動偵測，開發者不需手動設定。各 SDK 的偵測來源和映射規則：</p>
<table>
  <thead>
      <tr>
          <th>SDK</th>
          <th>偵測來源</th>
          <th>映射規則</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Python</td>
          <td><code>sys.platform</code></td>
          <td><code>darwin</code>→<code>macos</code>、<code>linux</code>→<code>linux</code>、<code>win32</code>→<code>windows</code>、其他直接傳原值</td>
      </tr>
      <tr>
          <td>Flutter</td>
          <td><code>Platform.operatingSystem</code></td>
          <td>回傳值（<code>ios</code>/<code>android</code>/<code>macos</code>/<code>linux</code>/<code>windows</code>）即合法值，無需映射</td>
      </tr>
      <tr>
          <td>JS</td>
          <td>瀏覽器環境</td>
          <td>固定為 <code>web</code>；OS 偵測（如需要）從 <code>navigator.userAgentData</code> 解析</td>
      </tr>
      <tr>
          <td>Go</td>
          <td><code>runtime.GOOS</code></td>
          <td><code>darwin</code>→<code>macos</code>、<code>linux</code>→<code>linux</code>、<code>windows</code>→<code>windows</code>、映射邏輯同 Python</td>
      </tr>
  </tbody>
</table>
<p>以上映射是 SDK init 時的預設自動偵測行為。Python 和 Go 的 runtime 回傳系統內部名稱（<code>darwin</code>、<code>win32</code>），SDK 負責映射到 schema 定義的標準名稱。Flutter 的 <code>dart:io Platform.operatingSystem</code> 恰好回傳合法值。JS SDK 在瀏覽器環境中無法可靠偵測 OS，platform 統一為 <code>web</code>。</p>
<p>自動偵測之外，SDK 也接受手動覆蓋 platform 值。短生命週期的命令列腳本（如 CI pipeline step、pre-commit hook）可手動將 platform 設為 <code>script</code>，表示非互動式 OS session——這類場景中 OS 名稱不是有意義的區分維度，<code>script</code> 讓查詢時能篩選出所有腳本來源的事件。</p>
<p>SDK 不做映射的話，collector 會收到不一致的 platform 值——同是 macOS 的事件有些標 <code>darwin</code> 有些標 <code>macos</code>，查詢篩選會漏事件。各平台 SDK 的執行環境適配細節見<a href="/blog/monitoring/05-platform-adaptation/" data-link-title="模組五：平台適配" data-link-desc="JS CORS / Flutter isolate / Python GIL / Go graceful shutdown — 各平台的特殊考量">模組五：平台適配</a>。</p>
<h3 id="session選填">session（選填）</h3>
<p>使用者 session 的識別資訊。Session ID（UUID）和 session 開始時間。</p>
<p>Session 用於關聯同一次使用中的多個事件。「使用者在這次 session 中做了什麼操作、遇到了什麼 error」的分析依賴 session ID。</p>
<p>去識別化要求：session ID 用 UUID 而非使用者帳號，不包含個人識別資訊（<a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">模組七</a>）。</p>
<h3 id="data選填">data（選填）</h3>
<p>事件的附加資料。自由結構的 JSON object，內容依事件類型和名稱而定。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;data&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nt">&#34;url&#34;</span><span class="p">:</span> <span class="s2">&#34;wss://192.168.1.100:7681/ws&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nt">&#34;duration_ms&#34;</span><span class="p">:</span> <span class="mi">320</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nt">&#34;step&#34;</span><span class="p">:</span> <span class="s2">&#34;3/5&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Data 欄位是 schema 中唯一的自由結構區域。核心欄位（type、name、timestamp、source）有固定格式，data 的內容由事件定義者決定。</p>
<h3 id="v必填">v（必填）</h3>
<p>Schema 版本號。整數，從 1 開始遞增。</p>
<p>版本號讓 collector 知道用哪個版本的 schema 驗證這筆事件。Schema 演進時，舊版本的事件仍可被正確處理。</p>
<h2 id="collector-附加欄位底線前綴">Collector 附加欄位（底線前綴）</h2>
<p>Collector 在事件寫入 storage 時可以附加系統層的 metadata。這些欄位使用底線前綴（<code>_flags</code>、<code>_fingerprint</code>），和 SDK 端產生的業務欄位區隔。SDK 送出的事件中不包含這些欄位 — 它們由 collector pipeline 在處理過程中計算並附加。</p>
<h3 id="_flags選填collector-附加">_flags（選填，collector 附加）</h3>
<p>Collector 端的行為分析或規則引擎偵測到異常時，在事件中附加標記。Dashboard 查詢可用 <code>_flags</code> 過濾可疑事件。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;_flags&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nt">&#34;suspicious&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nt">&#34;reason&#34;</span><span class="p">:</span> <span class="s2">&#34;rate_anomaly&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>suspicious</code> 標記的事件不被刪除 — 直接丟棄有誤殺正常流量的風險（行銷活動的真實流量暴增可能觸發異常偵測）。Dashboard 預設排除 <code>_flags.suspicious = true</code> 的事件，需要調查時可包含。</p>
<p>標記來源和 reason 值的定義見 <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> 的事後標記策略段。</p>
<h3 id="_fingerprint選填collector-附加">_fingerprint（選填，collector 附加）</h3>
<p>Error 事件的去重識別碼。Collector 從 error 的 type、normalized message、stack trace 計算 hash，用於把相同根因的 error 歸組。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;_fingerprint&#34;</span><span class="p">:</span> <span class="s2">&#34;a3f8c2e1b7d94f06&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Fingerprint 的計算邏輯和 error grouping 機制見 <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>
<h3 id="sdk-自監控指標">SDK 自監控指標</h3>
<p>監控系統自身的資料完整性需要獨立的指標追蹤 — SDK 用 metric 類事件回報自己的送出量和丟棄量，collector 用 endpoint 暴露處理量和拒絕量。SDK 端的指標每次 flush 成功後作為標準 schema 事件一起送出，name 以 <code>sdk.</code> 前綴標識。</p>
<table>
  <thead>
      <tr>
          <th>name</th>
          <th>含義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>sdk.events.produced</code></td>
          <td>事件產生總數（取樣前）</td>
      </tr>
      <tr>
          <td><code>sdk.events.sampled</code></td>
          <td>取樣後保留的事件數</td>
      </tr>
      <tr>
          <td><code>sdk.events.sent</code></td>
          <td>成功送出的事件數（收到 200/207 的 accepted）</td>
      </tr>
      <tr>
          <td><code>sdk.events.dropped</code></td>
          <td>被 FIFO 丟棄或重試耗盡的事件數</td>
      </tr>
      <tr>
          <td><code>sdk.flush.failures</code></td>
          <td>flush 失敗次數（429 / 5xx / timeout）</td>
      </tr>
      <tr>
          <td><code>sdk.sampling.rate</code></td>
          <td>當前動態取樣率</td>
      </tr>
  </tbody>
</table>
<p>Collector 端對應暴露 <code>collector.events.received</code>、<code>collector.events.rejected</code>、<code>collector.events.stored</code>、<code>collector.events.backpressure</code> 等指標，透過 <code>/metrics</code> endpoint 或 health endpoint 的擴展欄位提供。</p>
<p>完整的指標定義、端到端比對方法和損失率閾值見 <a href="/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性</a> 的監控損失段。</p>
<h2 id="完整-schema-範例">完整 schema 範例</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nt">&#34;v&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;error&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;terminal.connect.failed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nt">&#34;timestamp&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-06-19T14:30:00.123+08:00&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="nt">&#34;source&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nt">&#34;sdk&#34;</span><span class="p">:</span> <span class="s2">&#34;flutter&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nt">&#34;app&#34;</span><span class="p">:</span> <span class="s2">&#34;app_tunnel&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nt">&#34;version&#34;</span><span class="p">:</span> <span class="s2">&#34;1.2.0&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nt">&#34;platform&#34;</span><span class="p">:</span> <span class="s2">&#34;ios&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nt">&#34;os&#34;</span><span class="p">:</span> <span class="s2">&#34;17.4&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="nt">&#34;session&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;a1b2c3d4-e5f6-7890-abcd-ef1234567890&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nt">&#34;started&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-06-19T14:25:00.000+08:00&#34;</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="nt">&#34;data&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nt">&#34;step&#34;</span><span class="p">:</span> <span class="s2">&#34;ws_connect&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nt">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;Connection refused&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="nt">&#34;url&#34;</span><span class="p">:</span> <span class="s2">&#34;wss://192.168.1.100:7681/ws&#34;</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h2 id="下一步路由">下一步路由</h2>
<ul>
<li>欄位設計的原則 → <a href="/blog/monitoring/02-log-schema/field-design-principles/" data-link-title="欄位設計原則" data-link-desc="source 標明來源、data 自由欄位、v 版本演進 — 三個設計原則讓 schema 在不同階段都能使用">欄位設計原則</a></li>
<li>Schema 版本演進 → <a href="/blog/monitoring/02-log-schema/schema-versioning/" data-link-title="Schema 版本演進策略" data-link-desc="Backward compatible 的增量變更 — 新增欄位不改版、改名或改型別才改版、collector 同時支援多版本">Schema 版本演進策略</a></li>
<li>和 OpenTelemetry 的差異 → <a href="/blog/monitoring/02-log-schema/otel-comparison/" data-link-title="跟 OpenTelemetry 的 schema 差異對照" data-link-desc="自架 event schema 和 OTLP 的設計差異 — 為什麼 client-side 監控用簡化 schema、什麼時候切換到 OTLP">跟 OpenTelemetry 的 schema 差異對照</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>Funnel Analysis</title><link>https://tarrragon.github.io/blog/monitoring/knowledge-cards/funnel-analysis/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/knowledge-cards/funnel-analysis/</guid><description>&lt;p>Funnel analysis 的核心概念是「追蹤使用者在多步驟流程中每一步的轉換率和流失率」。每一步有多少使用者完成、多少使用者離開，構成漏斗形狀的轉換圖。可先對照 &lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/cohort-analysis/" data-link-title="Cohort Analysis" data-link-desc="說明把使用者按共同特徵分群、比較不同群組行為差異的分析方法">cohort analysis&lt;/a>（按群組比較留存）和 &lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/rfm/" data-link-title="RFM" data-link-desc="說明用 Recency / Frequency / Monetary 三個維度把使用者分成可操作群組的分群方法">RFM&lt;/a>（按行為分群）。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Funnel analysis 位在行為資料收集之後、產品決策之前。它的輸入是 event 類監控事件（使用者操作記錄），輸出是每步的轉換率。Funnel analysis 的前提是去識別化（&lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/redaction/" data-link-title="Redaction" data-link-desc="說明在事件資料離開 client 之前把敏感欄位的值替換成遮罩或移除的機制">redaction&lt;/a>）已完成 — 分析行為資料前必須確保資料不含可識別個人的敏感欄位。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>產品需要 funnel analysis 的訊號是「使用者在某個流程中的完成率低於預期，但不知道卡在哪一步」。註冊流程的轉換率從填寫 email 到完成驗證只有 30%，funnel analysis 揭露 60% 的使用者在「等待驗證信」步驟流失。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Funnel analysis 要定義步驟順序、步驟之間的時間窗口（使用者在多久內完成下一步才算轉換）、以及分群維度（按平台、來源、使用者類型拆分 funnel）。步驟定義需要和事件命名規範對齊 — funnel 的每一步對應一個具體的事件名稱。&lt;/p></description><content:encoded><![CDATA[<p>Funnel analysis 的核心概念是「追蹤使用者在多步驟流程中每一步的轉換率和流失率」。每一步有多少使用者完成、多少使用者離開，構成漏斗形狀的轉換圖。可先對照 <a href="/blog/monitoring/knowledge-cards/cohort-analysis/" data-link-title="Cohort Analysis" data-link-desc="說明把使用者按共同特徵分群、比較不同群組行為差異的分析方法">cohort analysis</a>（按群組比較留存）和 <a href="/blog/monitoring/knowledge-cards/rfm/" data-link-title="RFM" data-link-desc="說明用 Recency / Frequency / Monetary 三個維度把使用者分成可操作群組的分群方法">RFM</a>（按行為分群）。</p>
<h2 id="概念位置">概念位置</h2>
<p>Funnel analysis 位在行為資料收集之後、產品決策之前。它的輸入是 event 類監控事件（使用者操作記錄），輸出是每步的轉換率。Funnel analysis 的前提是去識別化（<a href="/blog/monitoring/knowledge-cards/redaction/" data-link-title="Redaction" data-link-desc="說明在事件資料離開 client 之前把敏感欄位的值替換成遮罩或移除的機制">redaction</a>）已完成 — 分析行為資料前必須確保資料不含可識別個人的敏感欄位。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>產品需要 funnel analysis 的訊號是「使用者在某個流程中的完成率低於預期，但不知道卡在哪一步」。註冊流程的轉換率從填寫 email 到完成驗證只有 30%，funnel analysis 揭露 60% 的使用者在「等待驗證信」步驟流失。</p>
<h2 id="設計責任">設計責任</h2>
<p>Funnel analysis 要定義步驟順序、步驟之間的時間窗口（使用者在多久內完成下一步才算轉換）、以及分群維度（按平台、來源、使用者類型拆分 funnel）。步驟定義需要和事件命名規範對齊 — funnel 的每一步對應一個具體的事件名稱。</p>
]]></content:encoded></item><item><title>JS/TS 平台適配</title><link>https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/js-ts-platform/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/js-ts-platform/</guid><description>&lt;p>瀏覽器環境中的監控 SDK 面臨三個平台特有的限制：跨域請求被 CORS 攔截、Service Worker 可以攔截和修改請求、SPA 的路由變換不觸發頁面載入事件。每個限制需要 SDK 在設計層面做適配。&lt;/p>
&lt;h2 id="cors-限制">CORS 限制&lt;/h2>
&lt;p>瀏覽器的同源政策限制網頁向不同 origin 發送請求。SDK 的 HTTP POST 送到 collector endpoint 時，如果 collector 和網頁不在同一個 origin（protocol + domain + port 都相同），瀏覽器會先發送 preflight OPTIONS 請求確認 server 允許跨域存取。&lt;/p>
&lt;p>SDK 端的適配：&lt;/p>
&lt;p>使用 &lt;code>navigator.sendBeacon(url, data)&lt;/code> 代替 &lt;code>fetch&lt;/code> / &lt;code>XMLHttpRequest&lt;/code>。sendBeacon 不受 CORS 限制（瀏覽器對 beacon 請求不做 preflight），且在頁面 unload 時仍能可靠送出 — 適合 close flush 場景。&lt;/p>
&lt;p>sendBeacon 的限制：payload 大小有上限（通常 64KB），不能自訂 Content-Type header（固定為 &lt;code>text/plain&lt;/code> 或 &lt;code>application/x-www-form-urlencoded&lt;/code>），沒有回應 — 送出後無法知道 server 是否收到。&lt;/p>
&lt;p>如果需要 fetch（例如需要讀取回應或送出大 payload），collector 端需要設定 CORS header：&lt;code>Access-Control-Allow-Origin&lt;/code>、&lt;code>Access-Control-Allow-Methods: POST&lt;/code>、&lt;code>Access-Control-Allow-Headers: Content-Type&lt;/code>。&lt;/p>
&lt;h2 id="service-worker-攔截">Service Worker 攔截&lt;/h2>
&lt;p>Service Worker 可以攔截頁面發出的所有 HTTP 請求（包括 SDK 的 POST 請求到 collector）。如果應用程式的 Service Worker 有 cache 策略（cache-first、network-first），SDK 的監控請求可能被快取而非送到 collector。&lt;/p>
&lt;p>SDK 端的適配：&lt;/p>
&lt;p>在 fetch 請求中加 &lt;code>cache: 'no-store'&lt;/code> 防止 Service Worker 快取監控請求。或在請求 URL 加唯一的 query parameter（&lt;code>?_t=timestamp&lt;/code>）讓每次請求的 URL 都不同，繞過 cache 比對。&lt;/p>
&lt;p>如果 SDK 本身提供 Service Worker 模組（在 Service Worker 內攔截 error），需要注意 Service Worker 的生命週期和頁面不同 — Service Worker 可能在頁面關閉後仍在執行，也可能在空閒時被瀏覽器終止。&lt;/p>
&lt;h2 id="spa-路由變換偵測">SPA 路由變換偵測&lt;/h2>
&lt;p>Single Page Application 的路由變換（React Router、Vue Router、Angular Router）不觸發頁面重新載入。從監控角度看，使用者在不同「頁面」之間切換，但 &lt;code>window.onload&lt;/code> 只在首次載入時觸發一次。&lt;/p>
&lt;p>SDK 需要偵測 SPA 路由變換來記錄 &lt;code>lifecycle.view.change&lt;/code> 事件。偵測方式：&lt;/p>
&lt;p>&lt;code>History API&lt;/code> 攔截：monkey-patch &lt;code>history.pushState&lt;/code> 和 &lt;code>history.replaceState&lt;/code>，在呼叫前後記錄路由變換。同時監聽 &lt;code>popstate&lt;/code> 事件處理瀏覽器的上一頁/下一頁。&lt;/p>
&lt;p>&lt;code>MutationObserver&lt;/code>：監聽 DOM 變化偵測頁面內容更新。但 MutationObserver 觸發頻率高，需要 debounce 並搭配 URL 變化檢查，避免把 DOM 微調誤判為路由變換。&lt;/p>
&lt;p>框架特定的 hook：如果 SDK 提供框架整合套件（React / Vue / Angular plugin），可以用框架的 router 事件（&lt;code>useNavigate&lt;/code> hook、&lt;code>router.afterEach&lt;/code> guard）直接取得路由變換資訊，比 monkey-patch History API 更可靠。&lt;/p>
&lt;p>JS/TS 的平台限制理解後，其他平台各有各的挑戰 — &lt;a href="https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/flutter-platform/" data-link-title="Flutter 平台適配" data-link-desc="Isolate 安全、Platform channel 攔截、app lifecycle 事件 — Flutter SDK 的平台特殊考量">Flutter 平台適配&lt;/a>處理 isolate 和 platform channel 的問題。所有平台共同面對的 &lt;a href="https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/cross-platform-timestamp/" data-link-title="跨平台 timestamp 一致性" data-link-desc="時區、精度、clock drift — 不同平台產生的 timestamp 在 collector 端需要能正確比對和排序">timestamp 一致性&lt;/a>問題（時區、精度、clock drift）在獨立章節中展開。SDK 的跨平台公開 API 設計見&lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/public-api/" data-link-title="SDK 公開 API 設計" data-link-desc="init / event / error / metric / flush / close 六個方法構成 SDK 的完整生命週期 — 跨平台共用相同 API 介面">模組三 SDK 公開 API&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>瀏覽器環境中的監控 SDK 面臨三個平台特有的限制：跨域請求被 CORS 攔截、Service Worker 可以攔截和修改請求、SPA 的路由變換不觸發頁面載入事件。每個限制需要 SDK 在設計層面做適配。</p>
<h2 id="cors-限制">CORS 限制</h2>
<p>瀏覽器的同源政策限制網頁向不同 origin 發送請求。SDK 的 HTTP POST 送到 collector endpoint 時，如果 collector 和網頁不在同一個 origin（protocol + domain + port 都相同），瀏覽器會先發送 preflight OPTIONS 請求確認 server 允許跨域存取。</p>
<p>SDK 端的適配：</p>
<p>使用 <code>navigator.sendBeacon(url, data)</code> 代替 <code>fetch</code> / <code>XMLHttpRequest</code>。sendBeacon 不受 CORS 限制（瀏覽器對 beacon 請求不做 preflight），且在頁面 unload 時仍能可靠送出 — 適合 close flush 場景。</p>
<p>sendBeacon 的限制：payload 大小有上限（通常 64KB），不能自訂 Content-Type header（固定為 <code>text/plain</code> 或 <code>application/x-www-form-urlencoded</code>），沒有回應 — 送出後無法知道 server 是否收到。</p>
<p>如果需要 fetch（例如需要讀取回應或送出大 payload），collector 端需要設定 CORS header：<code>Access-Control-Allow-Origin</code>、<code>Access-Control-Allow-Methods: POST</code>、<code>Access-Control-Allow-Headers: Content-Type</code>。</p>
<h2 id="service-worker-攔截">Service Worker 攔截</h2>
<p>Service Worker 可以攔截頁面發出的所有 HTTP 請求（包括 SDK 的 POST 請求到 collector）。如果應用程式的 Service Worker 有 cache 策略（cache-first、network-first），SDK 的監控請求可能被快取而非送到 collector。</p>
<p>SDK 端的適配：</p>
<p>在 fetch 請求中加 <code>cache: 'no-store'</code> 防止 Service Worker 快取監控請求。或在請求 URL 加唯一的 query parameter（<code>?_t=timestamp</code>）讓每次請求的 URL 都不同，繞過 cache 比對。</p>
<p>如果 SDK 本身提供 Service Worker 模組（在 Service Worker 內攔截 error），需要注意 Service Worker 的生命週期和頁面不同 — Service Worker 可能在頁面關閉後仍在執行，也可能在空閒時被瀏覽器終止。</p>
<h2 id="spa-路由變換偵測">SPA 路由變換偵測</h2>
<p>Single Page Application 的路由變換（React Router、Vue Router、Angular Router）不觸發頁面重新載入。從監控角度看，使用者在不同「頁面」之間切換，但 <code>window.onload</code> 只在首次載入時觸發一次。</p>
<p>SDK 需要偵測 SPA 路由變換來記錄 <code>lifecycle.view.change</code> 事件。偵測方式：</p>
<p><code>History API</code> 攔截：monkey-patch <code>history.pushState</code> 和 <code>history.replaceState</code>，在呼叫前後記錄路由變換。同時監聽 <code>popstate</code> 事件處理瀏覽器的上一頁/下一頁。</p>
<p><code>MutationObserver</code>：監聽 DOM 變化偵測頁面內容更新。但 MutationObserver 觸發頻率高，需要 debounce 並搭配 URL 變化檢查，避免把 DOM 微調誤判為路由變換。</p>
<p>框架特定的 hook：如果 SDK 提供框架整合套件（React / Vue / Angular plugin），可以用框架的 router 事件（<code>useNavigate</code> hook、<code>router.afterEach</code> guard）直接取得路由變換資訊，比 monkey-patch History API 更可靠。</p>
<p>JS/TS 的平台限制理解後，其他平台各有各的挑戰 — <a href="/blog/monitoring/05-platform-adaptation/flutter-platform/" data-link-title="Flutter 平台適配" data-link-desc="Isolate 安全、Platform channel 攔截、app lifecycle 事件 — Flutter SDK 的平台特殊考量">Flutter 平台適配</a>處理 isolate 和 platform channel 的問題。所有平台共同面對的 <a href="/blog/monitoring/05-platform-adaptation/cross-platform-timestamp/" data-link-title="跨平台 timestamp 一致性" data-link-desc="時區、精度、clock drift — 不同平台產生的 timestamp 在 collector 端需要能正確比對和排序">timestamp 一致性</a>問題（時區、精度、clock drift）在獨立章節中展開。SDK 的跨平台公開 API 設計見<a href="/blog/monitoring/03-sdk-design/public-api/" data-link-title="SDK 公開 API 設計" data-link-desc="init / event / error / metric / flush / close 六個方法構成 SDK 的完整生命週期 — 跨平台共用相同 API 介面">模組三 SDK 公開 API</a>。</p>
]]></content:encoded></item><item><title>SDK Redaction API 設計</title><link>https://tarrragon.github.io/blog/monitoring/07-security-privacy/sdk-redaction-api/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/07-security-privacy/sdk-redaction-api/</guid><description>&lt;p>Redaction 是在事件資料離開 client 之前，把敏感欄位的值替換成遮罩或移除。本章聚焦 redaction 的策略面 — 哪些資訊需要保護、保護的判斷依據和適用範圍。SDK 的 API 實作細節（初始化方式、helper 函式設計、和 flush 管線的整合）見 &lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/redaction-helper/" data-link-title="SDK redaction helper" data-link-desc="在事件離開 SDK 前移除敏感資訊 — 預設 redaction rule 處理常見 pattern，自訂 rule 處理業務特定的 secret">SDK redaction helper&lt;/a>。Redaction 在 SDK 端執行的設計原則是「敏感資料不離開 client」— 一旦資料送到 collector，即使 collector 有 access control，資料已經在網路上傳輸過，多了一層洩漏面。&lt;/p>
&lt;h2 id="預設-redaction-rule">預設 Redaction Rule&lt;/h2>
&lt;p>SDK 內建的 redaction rule 覆蓋最常見的敏感欄位模式。開發者不需要設定就能獲得基本保護。&lt;/p>
&lt;h3 id="欄位名稱比對">欄位名稱比對&lt;/h3>
&lt;p>以下欄位名稱（不分大小寫）的值自動替換為 &lt;code>[REDACTED]&lt;/code>：&lt;/p>
&lt;ul>
&lt;li>&lt;code>password&lt;/code>、&lt;code>passwd&lt;/code>、&lt;code>secret&lt;/code>、&lt;code>token&lt;/code>、&lt;code>api_key&lt;/code>、&lt;code>apiKey&lt;/code>&lt;/li>
&lt;li>&lt;code>authorization&lt;/code>、&lt;code>auth&lt;/code>、&lt;code>credential&lt;/code>&lt;/li>
&lt;li>&lt;code>ssn&lt;/code>、&lt;code>social_security&lt;/code>&lt;/li>
&lt;li>&lt;code>credit_card&lt;/code>、&lt;code>card_number&lt;/code>、&lt;code>cvv&lt;/code>、&lt;code>cvc&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>欄位名稱比對用 substring match — &lt;code>user_password&lt;/code> 包含 &lt;code>password&lt;/code> 會被 redact，&lt;code>password_reset_token&lt;/code> 包含 &lt;code>password&lt;/code> 和 &lt;code>token&lt;/code> 也會。&lt;/p>
&lt;h3 id="值格式比對">值格式比對&lt;/h3>
&lt;p>以下格式的值無論欄位名稱為何都自動替換：&lt;/p>
&lt;ul>
&lt;li>Email 地址格式（&lt;code>user@domain.com&lt;/code> → &lt;code>u***@domain.com&lt;/code>）&lt;/li>
&lt;li>信用卡號碼格式（連續 13-19 位數字 → 保留末四碼）&lt;/li>
&lt;li>Bearer token 格式（&lt;code>Bearer xxx&lt;/code> → &lt;code>Bearer [REDACTED]&lt;/code>）&lt;/li>
&lt;/ul>
&lt;p>值格式比對用正則表達式。正則的效能影響在大量事件時需要注意 — 預設 rule 的正則保持簡單，避免 catastrophic backtracking。&lt;/p>
&lt;h2 id="自訂-pattern">自訂 Pattern&lt;/h2>
&lt;p>應用可能有自己的 secret 格式，預設 rule 覆蓋不到。SDK 提供 API 讓開發者註冊自訂 redaction pattern。&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">monitor.addRedactionRule(
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> name: &amp;#39;internal-api-key&amp;#39;,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> pattern: RegExp(r&amp;#39;sk_live_[a-zA-Z0-9]{24}&amp;#39;),
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> replacement: &amp;#39;[REDACTED:api-key]&amp;#39;,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">monitor.addRedactionRule(
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> name: &amp;#39;database-url&amp;#39;,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> fieldNames: [&amp;#39;database_url&amp;#39;, &amp;#39;db_url&amp;#39;, &amp;#39;connection_string&amp;#39;],
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> replacement: &amp;#39;[REDACTED:db-url]&amp;#39;,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>自訂 pattern 的設計考量：&lt;/p>
&lt;p>&lt;strong>Pattern 在 init 時註冊&lt;/strong>。Redaction rule 在 SDK 初始化時設定，之後所有事件都通過這些 rule。不支援動態修改 — 避免「中途加 rule 導致之前的事件沒被 redact」的困惑。&lt;/p>
&lt;p>&lt;strong>Pattern 順序無關&lt;/strong>。所有 rule 獨立執行，不依賴順序。一個欄位可以匹配多個 rule，以第一個匹配的 replacement 為準。&lt;/p>
&lt;p>&lt;strong>Replacement 可以保留部分資訊&lt;/strong>。&lt;code>[REDACTED]&lt;/code> 完全遮蔽，&lt;code>[REDACTED:api-key]&lt;/code> 保留類型資訊，&lt;code>u***@domain.com&lt;/code> 保留結構。保留類型資訊對 debug 有幫助 — 看到 &lt;code>[REDACTED:api-key]&lt;/code> 至少知道這裡原本有一個 API key。&lt;/p>
&lt;h2 id="redaction-的適用範圍">Redaction 的適用範圍&lt;/h2>
&lt;p>Redaction 應用在 SDK 送出事件前的最後一步 — 在序列化（JSON encode）之前。適用範圍包括：&lt;/p>
&lt;ul>
&lt;li>Event 的 data 欄位（自由欄位，開發者可能放入任何內容）&lt;/li>
&lt;li>Error 的 stack trace（檔案路徑可能包含使用者名稱或部署路徑）&lt;/li>
&lt;li>Error 的 message（例外訊息可能包含 query string 或參數值）&lt;/li>
&lt;li>Lifecycle 的 metadata（連線 URL 可能包含認證資訊）&lt;/li>
&lt;/ul>
&lt;p>Redaction 不應用在 SDK 的內部欄位（timestamp、event type、session ID）— 這些是 SDK 自己產生的，不包含使用者資料。&lt;/p></description><content:encoded><![CDATA[<p>Redaction 是在事件資料離開 client 之前，把敏感欄位的值替換成遮罩或移除。本章聚焦 redaction 的策略面 — 哪些資訊需要保護、保護的判斷依據和適用範圍。SDK 的 API 實作細節（初始化方式、helper 函式設計、和 flush 管線的整合）見 <a href="/blog/monitoring/03-sdk-design/redaction-helper/" data-link-title="SDK redaction helper" data-link-desc="在事件離開 SDK 前移除敏感資訊 — 預設 redaction rule 處理常見 pattern，自訂 rule 處理業務特定的 secret">SDK redaction helper</a>。Redaction 在 SDK 端執行的設計原則是「敏感資料不離開 client」— 一旦資料送到 collector，即使 collector 有 access control，資料已經在網路上傳輸過，多了一層洩漏面。</p>
<h2 id="預設-redaction-rule">預設 Redaction Rule</h2>
<p>SDK 內建的 redaction rule 覆蓋最常見的敏感欄位模式。開發者不需要設定就能獲得基本保護。</p>
<h3 id="欄位名稱比對">欄位名稱比對</h3>
<p>以下欄位名稱（不分大小寫）的值自動替換為 <code>[REDACTED]</code>：</p>
<ul>
<li><code>password</code>、<code>passwd</code>、<code>secret</code>、<code>token</code>、<code>api_key</code>、<code>apiKey</code></li>
<li><code>authorization</code>、<code>auth</code>、<code>credential</code></li>
<li><code>ssn</code>、<code>social_security</code></li>
<li><code>credit_card</code>、<code>card_number</code>、<code>cvv</code>、<code>cvc</code></li>
</ul>
<p>欄位名稱比對用 substring match — <code>user_password</code> 包含 <code>password</code> 會被 redact，<code>password_reset_token</code> 包含 <code>password</code> 和 <code>token</code> 也會。</p>
<h3 id="值格式比對">值格式比對</h3>
<p>以下格式的值無論欄位名稱為何都自動替換：</p>
<ul>
<li>Email 地址格式（<code>user@domain.com</code> → <code>u***@domain.com</code>）</li>
<li>信用卡號碼格式（連續 13-19 位數字 → 保留末四碼）</li>
<li>Bearer token 格式（<code>Bearer xxx</code> → <code>Bearer [REDACTED]</code>）</li>
</ul>
<p>值格式比對用正則表達式。正則的效能影響在大量事件時需要注意 — 預設 rule 的正則保持簡單，避免 catastrophic backtracking。</p>
<h2 id="自訂-pattern">自訂 Pattern</h2>
<p>應用可能有自己的 secret 格式，預設 rule 覆蓋不到。SDK 提供 API 讓開發者註冊自訂 redaction pattern。</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">monitor.addRedactionRule(
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  name: &#39;internal-api-key&#39;,
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  pattern: RegExp(r&#39;sk_live_[a-zA-Z0-9]{24}&#39;),
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  replacement: &#39;[REDACTED:api-key]&#39;,
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">)
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">monitor.addRedactionRule(
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  name: &#39;database-url&#39;,
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  fieldNames: [&#39;database_url&#39;, &#39;db_url&#39;, &#39;connection_string&#39;],
</span></span><span class="line"><span class="ln">10</span><span class="cl">  replacement: &#39;[REDACTED:db-url]&#39;,
</span></span><span class="line"><span class="ln">11</span><span class="cl">)</span></span></code></pre></div><p>自訂 pattern 的設計考量：</p>
<p><strong>Pattern 在 init 時註冊</strong>。Redaction rule 在 SDK 初始化時設定，之後所有事件都通過這些 rule。不支援動態修改 — 避免「中途加 rule 導致之前的事件沒被 redact」的困惑。</p>
<p><strong>Pattern 順序無關</strong>。所有 rule 獨立執行，不依賴順序。一個欄位可以匹配多個 rule，以第一個匹配的 replacement 為準。</p>
<p><strong>Replacement 可以保留部分資訊</strong>。<code>[REDACTED]</code> 完全遮蔽，<code>[REDACTED:api-key]</code> 保留類型資訊，<code>u***@domain.com</code> 保留結構。保留類型資訊對 debug 有幫助 — 看到 <code>[REDACTED:api-key]</code> 至少知道這裡原本有一個 API key。</p>
<h2 id="redaction-的適用範圍">Redaction 的適用範圍</h2>
<p>Redaction 應用在 SDK 送出事件前的最後一步 — 在序列化（JSON encode）之前。適用範圍包括：</p>
<ul>
<li>Event 的 data 欄位（自由欄位，開發者可能放入任何內容）</li>
<li>Error 的 stack trace（檔案路徑可能包含使用者名稱或部署路徑）</li>
<li>Error 的 message（例外訊息可能包含 query string 或參數值）</li>
<li>Lifecycle 的 metadata（連線 URL 可能包含認證資訊）</li>
</ul>
<p>Redaction 不應用在 SDK 的內部欄位（timestamp、event type、session ID）— 這些是 SDK 自己產生的，不包含使用者資料。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>資料離開 client 後的保護 → <a href="/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全</a></li>
<li>去識別化策略 → <a href="/blog/monitoring/07-security-privacy/anonymization-strategy/" data-link-title="去識別化策略" data-link-desc="IP 截斷 / user agent 簡化 / stack trace 路徑清理 / session UUID — 四種去識別化技術的適用場景和實作方式">去識別化策略</a></li>
<li>IME 個人化學習的 secret 洩漏風險 → <a href="/blog/ux-design/03-input-mechanism/ime-security-checklist/" data-link-title="安全敏感輸入框的 IME 控制 checklist" data-link-desc="處理密碼、API key、伺服器路徑等 secret 的輸入框需要關閉 IME 的個人化學習和自動校正 — 安全要求而非 UX 偏好">ux-design 模組三 IME 安全 checklist</a></li>
</ul>
]]></content:encoded></item><item><title>SDK 公開 API 設計</title><link>https://tarrragon.github.io/blog/monitoring/03-sdk-design/public-api/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/03-sdk-design/public-api/</guid><description>&lt;p>SDK 的公開 API 是應用程式和監控系統之間的契約。六個方法涵蓋 SDK 的完整生命週期：初始化、四類事件上報、資料送出控制和資源釋放。跨平台的 SDK（JS / Flutter / Python）共用相同的方法簽名，讓開發者在不同平台上使用一致的 API。&lt;/p>
&lt;h2 id="六個方法">六個方法&lt;/h2>
&lt;h3 id="init">init&lt;/h3>
&lt;p>SDK 初始化。設定 collector endpoint、app 識別資訊、flush 間隔、buffer 大小。在 app 啟動時呼叫一次。&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">Monitor.init({
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> endpoint: &amp;#39;https://collector.example.com/v1/events&amp;#39;,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> app: &amp;#39;my_app&amp;#39;,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> version: &amp;#39;1.2.0&amp;#39;,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> flushInterval: 30000, // 毫秒
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> bufferSize: 100,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">})&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>init 負責建立 session、記錄 lifecycle.session.start 事件、啟動 flush 計時器。init 之前呼叫其他方法應該拋出明確錯誤（SDK 未初始化），而非靜默忽略。&lt;/p>
&lt;p>&lt;strong>連線驗證策略：lazy&lt;/strong>。init 不驗證 collector 是否可達 — 不發 HTTP 請求、不 ping endpoint。init 的失敗只代表配置錯誤（缺少 endpoint 參數），不代表網路問題。網路問題在第一次 flush 時才浮現，flush 失敗時事件保留在 buffer 等待重試。&lt;/p>
&lt;p>Lazy 策略的理由：SDK 不應阻塞主程式的啟動流程。如果 init 驗證連線，collector 暫時不可用時 app 會啟動失敗 — 監控工具反而變成可用性的瓶頸。短生命週期腳本（&lt;a href="https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/python-platform/" data-link-title="Python 平台適配" data-link-desc="GIL 與 threading、atexit 可靠性、subprocess 監控 — Python SDK 的平台特殊考量">Python 平台適配：短生命週期腳本&lt;/a>）對這一點更敏感 — hook 腳本不能因為 collector 沒啟動就拒絕執行。&lt;/p>
&lt;h3 id="event">event&lt;/h3>
&lt;p>記錄使用者操作事件（&lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/four-event-types/" data-link-title="四類事件的完整定義" data-link-desc="Event / Error / Metric / Lifecycle 四類事件各自的語意、觸發時機和典型用途 — 分類是監控體系的統一語言">四類事件中的 Event 類&lt;/a>）。接受事件名稱和可選的 data 物件。&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">Monitor.event(&amp;#39;terminal.connect.start&amp;#39;, { url: &amp;#39;wss://...&amp;#39; })
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">Monitor.event(&amp;#39;enrollment.qr.scan&amp;#39;)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>event 方法是非阻塞的 — 事件進入內部 buffer 立即返回，不等待網路送出。應用程式的操作流程不應該被監控 SDK 的網路延遲阻塞。&lt;/p>
&lt;h3 id="error">error&lt;/h3>
&lt;p>記錄錯誤事件。接受 Error/Exception 物件或自訂的錯誤描述。自動附加 stack trace、錯誤類型、觸發位置。&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">Monitor.error(exception, { step: &amp;#39;ws_connect&amp;#39; })
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">Monitor.error(&amp;#39;Auth token missing&amp;#39;, { context: &amp;#39;handshake&amp;#39; })&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>error 方法和自動攔截機制（&lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/auto-intercept/" data-link-title="自動攔截機制" data-link-desc="JS window.onerror / Flutter FlutterError.onError / Python sys.excepthook — 各平台攔截未捕獲例外的機制和限制">自動攔截&lt;/a>）互補 — 自動攔截處理未捕獲的例外，error 方法處理開發者主動上報的已知錯誤。&lt;/p>
&lt;h3 id="metric">metric&lt;/h3>
&lt;p>記錄數值指標。接受指標名稱和數值。&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">Monitor.metric(&amp;#39;connect.duration_ms&amp;#39;, 320)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">Monitor.metric(&amp;#39;terminal.fps&amp;#39;, 58.5)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>metric 方法記錄的是離散的數值快照。聚合計算（平均、百分位、趨勢）在 collector 端完成，SDK 端只負責記錄原始值。&lt;/p>
&lt;h3 id="flush">flush&lt;/h3>
&lt;p>強制送出 buffer 中所有待發事件。正常情況下 SDK 按 flushInterval 定期自動 flush（&lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/batch-flush/" data-link-title="攢批送出策略" data-link-desc="flush interval / buffer size / flush on close 三個控制點決定事件何時離開 SDK — 平衡即時性和網路效率">攢批送出&lt;/a>）。flush 方法用於需要確保事件已送出的場景 — 例如 app 即將進入背景或使用者手動觸發 log 上傳。&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">await Monitor.flush()&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>flush 是非同步方法 — 需要等待網路請求完成。呼叫端可以 await 確認送出成功，也可以 fire-and-forget。&lt;/p></description><content:encoded><![CDATA[<p>SDK 的公開 API 是應用程式和監控系統之間的契約。六個方法涵蓋 SDK 的完整生命週期：初始化、四類事件上報、資料送出控制和資源釋放。跨平台的 SDK（JS / Flutter / Python）共用相同的方法簽名，讓開發者在不同平台上使用一致的 API。</p>
<h2 id="六個方法">六個方法</h2>
<h3 id="init">init</h3>
<p>SDK 初始化。設定 collector endpoint、app 識別資訊、flush 間隔、buffer 大小。在 app 啟動時呼叫一次。</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">Monitor.init({
</span></span><span class="line"><span class="ln">2</span><span class="cl">  endpoint: &#39;https://collector.example.com/v1/events&#39;,
</span></span><span class="line"><span class="ln">3</span><span class="cl">  app: &#39;my_app&#39;,
</span></span><span class="line"><span class="ln">4</span><span class="cl">  version: &#39;1.2.0&#39;,
</span></span><span class="line"><span class="ln">5</span><span class="cl">  flushInterval: 30000,   // 毫秒
</span></span><span class="line"><span class="ln">6</span><span class="cl">  bufferSize: 100,
</span></span><span class="line"><span class="ln">7</span><span class="cl">})</span></span></code></pre></div><p>init 負責建立 session、記錄 lifecycle.session.start 事件、啟動 flush 計時器。init 之前呼叫其他方法應該拋出明確錯誤（SDK 未初始化），而非靜默忽略。</p>
<p><strong>連線驗證策略：lazy</strong>。init 不驗證 collector 是否可達 — 不發 HTTP 請求、不 ping endpoint。init 的失敗只代表配置錯誤（缺少 endpoint 參數），不代表網路問題。網路問題在第一次 flush 時才浮現，flush 失敗時事件保留在 buffer 等待重試。</p>
<p>Lazy 策略的理由：SDK 不應阻塞主程式的啟動流程。如果 init 驗證連線，collector 暫時不可用時 app 會啟動失敗 — 監控工具反而變成可用性的瓶頸。短生命週期腳本（<a href="/blog/monitoring/05-platform-adaptation/python-platform/" data-link-title="Python 平台適配" data-link-desc="GIL 與 threading、atexit 可靠性、subprocess 監控 — Python SDK 的平台特殊考量">Python 平台適配：短生命週期腳本</a>）對這一點更敏感 — hook 腳本不能因為 collector 沒啟動就拒絕執行。</p>
<h3 id="event">event</h3>
<p>記錄使用者操作事件（<a href="/blog/monitoring/01-mental-model/four-event-types/" data-link-title="四類事件的完整定義" data-link-desc="Event / Error / Metric / Lifecycle 四類事件各自的語意、觸發時機和典型用途 — 分類是監控體系的統一語言">四類事件中的 Event 類</a>）。接受事件名稱和可選的 data 物件。</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">Monitor.event(&#39;terminal.connect.start&#39;, { url: &#39;wss://...&#39; })
</span></span><span class="line"><span class="ln">2</span><span class="cl">Monitor.event(&#39;enrollment.qr.scan&#39;)</span></span></code></pre></div><p>event 方法是非阻塞的 — 事件進入內部 buffer 立即返回，不等待網路送出。應用程式的操作流程不應該被監控 SDK 的網路延遲阻塞。</p>
<h3 id="error">error</h3>
<p>記錄錯誤事件。接受 Error/Exception 物件或自訂的錯誤描述。自動附加 stack trace、錯誤類型、觸發位置。</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">Monitor.error(exception, { step: &#39;ws_connect&#39; })
</span></span><span class="line"><span class="ln">2</span><span class="cl">Monitor.error(&#39;Auth token missing&#39;, { context: &#39;handshake&#39; })</span></span></code></pre></div><p>error 方法和自動攔截機制（<a href="/blog/monitoring/03-sdk-design/auto-intercept/" data-link-title="自動攔截機制" data-link-desc="JS window.onerror / Flutter FlutterError.onError / Python sys.excepthook — 各平台攔截未捕獲例外的機制和限制">自動攔截</a>）互補 — 自動攔截處理未捕獲的例外，error 方法處理開發者主動上報的已知錯誤。</p>
<h3 id="metric">metric</h3>
<p>記錄數值指標。接受指標名稱和數值。</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">Monitor.metric(&#39;connect.duration_ms&#39;, 320)
</span></span><span class="line"><span class="ln">2</span><span class="cl">Monitor.metric(&#39;terminal.fps&#39;, 58.5)</span></span></code></pre></div><p>metric 方法記錄的是離散的數值快照。聚合計算（平均、百分位、趨勢）在 collector 端完成，SDK 端只負責記錄原始值。</p>
<h3 id="flush">flush</h3>
<p>強制送出 buffer 中所有待發事件。正常情況下 SDK 按 flushInterval 定期自動 flush（<a href="/blog/monitoring/03-sdk-design/batch-flush/" data-link-title="攢批送出策略" data-link-desc="flush interval / buffer size / flush on close 三個控制點決定事件何時離開 SDK — 平衡即時性和網路效率">攢批送出</a>）。flush 方法用於需要確保事件已送出的場景 — 例如 app 即將進入背景或使用者手動觸發 log 上傳。</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">await Monitor.flush()</span></span></code></pre></div><p>flush 是非同步方法 — 需要等待網路請求完成。呼叫端可以 await 確認送出成功，也可以 fire-and-forget。</p>
<h3 id="close">close</h3>
<p>SDK 資源釋放。停止 flush 計時器、送出 buffer 中剩餘事件、關閉網路連線、記錄 lifecycle.session.end 事件。</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">await Monitor.close()</span></span></code></pre></div><p>close 在 app 關閉時呼叫。呼叫後 SDK 進入已關閉狀態，後續的 event/error/metric 呼叫應該被靜默忽略（不拋錯，因為 app 正在關閉）。</p>
<h2 id="api-設計原則">API 設計原則</h2>
<p><strong>方法名稱和四類事件對齊</strong>。event / error / metric 三個方法直接對應三類事件，lifecycle 事件由 init 和 close 自動產生。開發者看到方法名稱就知道對應哪類事件。</p>
<p><strong>所有上報方法非阻塞</strong>。event、error、metric 進 buffer 立即返回。監控 SDK 阻塞應用程式的操作流程是反模式。</p>
<p><strong>init 和 close 成對出現</strong>。init 開始 session，close 結束 session。兩者界定 SDK 的活躍期間。</p>
<p>各平台的 SDK 整合範例（Flutter 的 pubspec.yaml + main.dart init、Python 的 pip install + init code、JS 的 script tag + init）見 monitor repo 各 SDK 的 README。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>自動攔截未捕獲的錯誤 → <a href="/blog/monitoring/03-sdk-design/auto-intercept/" data-link-title="自動攔截機制" data-link-desc="JS window.onerror / Flutter FlutterError.onError / Python sys.excepthook — 各平台攔截未捕獲例外的機制和限制">自動攔截機制</a></li>
<li>Buffer 和 flush 的策略 → <a href="/blog/monitoring/03-sdk-design/batch-flush/" data-link-title="攢批送出策略" data-link-desc="flush interval / buffer size / flush on close 三個控制點決定事件何時離開 SDK — 平衡即時性和網路效率">攢批送出策略</a></li>
<li>SDK 端的資料脫敏 → <a href="/blog/monitoring/03-sdk-design/redaction-helper/" data-link-title="SDK redaction helper" data-link-desc="在事件離開 SDK 前移除敏感資訊 — 預設 redaction rule 處理常見 pattern，自訂 rule 處理業務特定的 secret">SDK redaction helper</a></li>
<li>SDK 的 HTTP POST 行為需要 protocol test → <a href="/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">testing 模組三 協議整合測試</a></li>
</ul>
]]></content:encoded></item><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>自架 vs 商業的判斷決策表</title><link>https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/self-hosted-vs-commercial/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/self-hosted-vs-commercial/</guid><description>&lt;p>自架監控和商業方案之間的選擇取決於四個維度的組合。每個維度有明確的閾值 — 超過閾值時自架的成本開始高於商業方案的訂閱費。&lt;/p>
&lt;h2 id="四個判斷維度">四個判斷維度&lt;/h2>
&lt;h3 id="使用者數">使用者數&lt;/h3>
&lt;p>自架方案的成本和使用者數幾乎無關（JSONL + grep 處理 1 個和 100 個使用者的成本差異很小）。商業方案按事件量或使用者數計費，使用者數增長直接推高費用。&lt;/p>
&lt;p>&lt;strong>經驗估算&lt;/strong>：使用者數在百人以下時，自架的總成本（開發 + 維護 + 硬體）通常低於商業方案的年費（以典型商業方案年費 $300-$600 和自架的開發維護時間估算）。使用者數在千人以上時，自架需要投入的基礎設施維護（高可用、擴容、備份）成本上升，商業方案的規模經濟開始有優勢。具體的交叉點取決於選用的 vendor 定價（Sentry Developer plan 免費額度 5000 events/月、PostHog 免費到 1M events/月）和自架的維護時間成本。&lt;/p>
&lt;p>兩者之間是灰色地帶 — 取決於功能需求和團隊能力。&lt;/p>
&lt;h3 id="網路範圍">網路範圍&lt;/h3>
&lt;p>使用者和 collector 是否在同一個網路內。&lt;/p>
&lt;p>&lt;strong>同一網路&lt;/strong>（自用工具、內部工具）：自架方案直接 HTTP POST 到本機或內網 endpoint，不需要 DNS、TLS 憑證、CDN。成本極低。&lt;/p>
&lt;p>&lt;strong>外部網路&lt;/strong>（公開 app、SaaS）：自架方案需要處理公網暴露、DDoS 防護、TLS 憑證管理、高可用（多區域部署）。商業方案把這些基礎設施問題內化了。&lt;/p>
&lt;h3 id="功能需求">功能需求&lt;/h3>
&lt;p>自架方案的功能上限是開發者願意投入的工程量。grep + jq 能做基礎查詢和 funnel 分析（&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 工具到商業資產的翻轉">模組八 自架 funnel&lt;/a>）。Dashboard、告警、session replay、A/B test 分群每個功能都是數週到數月的開發量。&lt;/p>
&lt;p>商業方案的功能開箱即用。如果需求包含 session replay、A/B test dashboard、自動 issue 分群，商業方案的功能完成度遠高於自架。&lt;/p>
&lt;h3 id="合規要求">合規要求&lt;/h3>
&lt;p>資料必須存放在特定地區（GDPR data residency）或不能離開公司網路（金融、醫療）。&lt;/p>
&lt;p>&lt;strong>自架&lt;/strong>：資料完全在自己的基礎設施上，資料位置由自己控制。適合最嚴格的合規要求。&lt;/p>
&lt;p>&lt;strong>商業方案&lt;/strong>：資料存放在 vendor 的基礎設施上。部分 vendor 提供 data residency 選項（Sentry 的 EU hosting、Datadog 的 EU region），但仍然是第三方持有資料。&lt;/p>
&lt;h2 id="決策表">決策表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>自架有利&lt;/th>
 &lt;th>商業方案有利&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>使用者數&lt;/td>
 &lt;td>&amp;lt; 100&lt;/td>
 &lt;td>&amp;gt; 1000&lt;/td>
 &lt;/tr>
 &lt;tr>
 &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>Dashboard + 告警 + replay&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>合規要求&lt;/td>
 &lt;td>資料不能離開自有設施&lt;/td>
 &lt;td>無特殊限制&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>四個維度中三個以上指向同一方向 → 選那個方向。兩兩對半 → 從自架開始（成本低、可逆），需求增長後再評估切換。&lt;/p>
&lt;p>決策表指向商業方案後，&lt;a href="https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/sentry-deep-dive/" data-link-title="Sentry 深入" data-link-desc="Error tracking &amp;#43; performance monitoring &amp;#43; session replay 的架構 — Sentry 從 error-first 出發如何擴展到全面可觀測性">Sentry 深入&lt;/a>和 &lt;a href="https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/firebase-suite/" data-link-title="Firebase 套件" data-link-desc="Crashlytics &amp;#43; Analytics &amp;#43; Remote Config 的整合 — Firebase 把 error tracking 和行為分析拆成獨立產品的設計取捨">Firebase 套件&lt;/a>分別展開兩個主流方案的架構和能力邊界。決策表指向自架時，&lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計&lt;/a>提供從 HTTP endpoint 到 rule engine 的完整實作藍圖。Server-side 的可觀測性（OTLP、Prometheus、Grafana）見 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend 模組四 可觀測性&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>自架監控和商業方案之間的選擇取決於四個維度的組合。每個維度有明確的閾值 — 超過閾值時自架的成本開始高於商業方案的訂閱費。</p>
<h2 id="四個判斷維度">四個判斷維度</h2>
<h3 id="使用者數">使用者數</h3>
<p>自架方案的成本和使用者數幾乎無關（JSONL + grep 處理 1 個和 100 個使用者的成本差異很小）。商業方案按事件量或使用者數計費，使用者數增長直接推高費用。</p>
<p><strong>經驗估算</strong>：使用者數在百人以下時，自架的總成本（開發 + 維護 + 硬體）通常低於商業方案的年費（以典型商業方案年費 $300-$600 和自架的開發維護時間估算）。使用者數在千人以上時，自架需要投入的基礎設施維護（高可用、擴容、備份）成本上升，商業方案的規模經濟開始有優勢。具體的交叉點取決於選用的 vendor 定價（Sentry Developer plan 免費額度 5000 events/月、PostHog 免費到 1M events/月）和自架的維護時間成本。</p>
<p>兩者之間是灰色地帶 — 取決於功能需求和團隊能力。</p>
<h3 id="網路範圍">網路範圍</h3>
<p>使用者和 collector 是否在同一個網路內。</p>
<p><strong>同一網路</strong>（自用工具、內部工具）：自架方案直接 HTTP POST 到本機或內網 endpoint，不需要 DNS、TLS 憑證、CDN。成本極低。</p>
<p><strong>外部網路</strong>（公開 app、SaaS）：自架方案需要處理公網暴露、DDoS 防護、TLS 憑證管理、高可用（多區域部署）。商業方案把這些基礎設施問題內化了。</p>
<h3 id="功能需求">功能需求</h3>
<p>自架方案的功能上限是開發者願意投入的工程量。grep + jq 能做基礎查詢和 funnel 分析（<a href="/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">模組八 自架 funnel</a>）。Dashboard、告警、session replay、A/B test 分群每個功能都是數週到數月的開發量。</p>
<p>商業方案的功能開箱即用。如果需求包含 session replay、A/B test dashboard、自動 issue 分群，商業方案的功能完成度遠高於自架。</p>
<h3 id="合規要求">合規要求</h3>
<p>資料必須存放在特定地區（GDPR data residency）或不能離開公司網路（金融、醫療）。</p>
<p><strong>自架</strong>：資料完全在自己的基礎設施上，資料位置由自己控制。適合最嚴格的合規要求。</p>
<p><strong>商業方案</strong>：資料存放在 vendor 的基礎設施上。部分 vendor 提供 data residency 選項（Sentry 的 EU hosting、Datadog 的 EU region），但仍然是第三方持有資料。</p>
<h2 id="決策表">決策表</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>自架有利</th>
          <th>商業方案有利</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>使用者數</td>
          <td>&lt; 100</td>
          <td>&gt; 1000</td>
      </tr>
      <tr>
          <td>網路範圍</td>
          <td>同一網路</td>
          <td>外部網路</td>
      </tr>
      <tr>
          <td>功能需求</td>
          <td>查詢 + 基礎分析</td>
          <td>Dashboard + 告警 + replay</td>
      </tr>
      <tr>
          <td>合規要求</td>
          <td>資料不能離開自有設施</td>
          <td>無特殊限制</td>
      </tr>
  </tbody>
</table>
<p>四個維度中三個以上指向同一方向 → 選那個方向。兩兩對半 → 從自架開始（成本低、可逆），需求增長後再評估切換。</p>
<p>決策表指向商業方案後，<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>和 <a href="/blog/monitoring/06-commercial-comparison/firebase-suite/" data-link-title="Firebase 套件" data-link-desc="Crashlytics &#43; Analytics &#43; Remote Config 的整合 — Firebase 把 error tracking 和行為分析拆成獨立產品的設計取捨">Firebase 套件</a>分別展開兩個主流方案的架構和能力邊界。決策表指向自架時，<a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計</a>提供從 HTTP endpoint 到 rule engine 的完整實作藍圖。Server-side 的可觀測性（OTLP、Prometheus、Grafana）見 <a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend 模組四 可觀測性</a>。</p>
<h2 id="中間路線">中間路線</h2>
<p>上表是「完全自架 vs 專業監控 SaaS」的兩端。中間還有兩條路徑 — 用 BaaS（Supabase + Vercel）搭出託管版 collector，或用 PaaS（Railway / Fly.io）跑自架 collector 原始碼但不管 server。APP 上線初期用免費方案零成本起步、保留自訂 schema 彈性是常見的起步策略。完整的四條路徑比較、架構差異、免費方案限額和遷移路線見<a href="/blog/monitoring/06-commercial-comparison/deployment-spectrum/" data-link-title="部署光譜：從 BaaS 到自架的四條路徑" data-link-desc="監控方案的部署選擇不是二元的 — BaaS &#43; Serverless 和 PaaS 是完全自架和商業 SaaS 之間兩條常被忽略的中間路徑">部署光譜</a>。</p>
]]></content:encoded></item><item><title>行為事件設計</title><link>https://tarrragon.github.io/blog/monitoring/08-business-analytics/behavior-event-design/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/08-business-analytics/behavior-event-design/</guid><description>&lt;p>行為事件是使用者操作的結構化記錄，每一筆事件回答「誰、在什麼時候、做了什麼、結果如何」。行為分析的品質上限由事件設計決定 — 事件粒度太粗無法回答細節問題，事件粒度太細讓儲存和查詢成本失控。&lt;/p>
&lt;h2 id="事件命名">事件命名&lt;/h2>
&lt;p>行為事件的命名遵循 &lt;code>namespace.action&lt;/code> 格式（&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>）。行為分析場景對命名的額外要求是：同一個 funnel 內的事件要能用 namespace 前綴篩選。&lt;/p>
&lt;p>例：註冊流程的事件用共同前綴 &lt;code>signup&lt;/code>：&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">signup.page.view 使用者看到註冊頁
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">signup.form.submit 使用者送出表單
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">signup.email.verify 使用者點擊驗證信連結
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">signup.complete 註冊完成&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>用 &lt;code>signup.*&lt;/code> 就能篩選出整個註冊流程的事件，不需要事先知道每一步的完整名稱。&lt;/p>
&lt;h2 id="屬性設計">屬性設計&lt;/h2>
&lt;p>每個事件除了名稱，還帶有屬性（properties / parameters）描述事件的 context。屬性分成三層：&lt;/p>
&lt;h3 id="通用屬性每個事件都有">通用屬性（每個事件都有）&lt;/h3>
&lt;ul>
&lt;li>&lt;code>timestamp&lt;/code>：事件發生的時間（UTC，毫秒精度）&lt;/li>
&lt;li>&lt;code>session_id&lt;/code>：當次使用的 session 識別碼&lt;/li>
&lt;li>&lt;code>user_id&lt;/code>：使用者識別碼（去識別化後，見 &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">模組七&lt;/a>）&lt;/li>
&lt;li>&lt;code>platform&lt;/code>：iOS / Android / Web&lt;/li>
&lt;li>&lt;code>app_version&lt;/code>：app 版本號&lt;/li>
&lt;/ul>
&lt;h3 id="事件類型屬性同類事件共有">事件類型屬性（同類事件共有）&lt;/h3>
&lt;ul>
&lt;li>頁面瀏覽事件：&lt;code>page_name&lt;/code>、&lt;code>referrer&lt;/code>&lt;/li>
&lt;li>按鈕點擊事件：&lt;code>button_id&lt;/code>、&lt;code>button_text&lt;/code>&lt;/li>
&lt;li>搜尋事件：&lt;code>query&lt;/code>、&lt;code>result_count&lt;/code>&lt;/li>
&lt;/ul>
&lt;h3 id="事件專屬屬性特定事件才有">事件專屬屬性（特定事件才有）&lt;/h3>
&lt;ul>
&lt;li>&lt;code>signup.form.submit&lt;/code>：&lt;code>form_method&lt;/code>（email / Google / Apple）&lt;/li>
&lt;li>&lt;code>purchase.complete&lt;/code>：&lt;code>amount&lt;/code>、&lt;code>currency&lt;/code>、&lt;code>product_id&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>屬性設計的判斷標準是：這個屬性是否用於回答一個分析問題。「註冊方式的轉換率差異」需要 &lt;code>form_method&lt;/code> 屬性；如果沒有這個分析問題，就不需要這個屬性。&lt;/p>
&lt;h2 id="funnel-定義">Funnel 定義&lt;/h2>
&lt;p>Funnel 是一連串有順序的事件，代表使用者完成一個目標的步驟。Funnel 定義在事件設計階段完成 — 決定哪些事件構成一個 funnel、順序是什麼、每步之間的最大時間間隔。&lt;/p>
&lt;p>定義一個 funnel 需要：&lt;/p>
&lt;p>&lt;strong>步驟清單&lt;/strong>：funnel 包含哪些事件，順序是什麼。&lt;/p>
&lt;p>&lt;strong>時間窗口&lt;/strong>：步驟之間的最大間隔。使用者在步驟 A 之後 30 天才做步驟 B，是否算在同一個 funnel 內？時間窗口的設定取決於業務場景 — 電商結帳 funnel 通常是 30 分鐘，SaaS onboarding funnel 可能是 7 天。&lt;/p>
&lt;p>&lt;strong>完成條件&lt;/strong>：什麼算「完成」funnel。到達最後一步即完成，還是需要特定屬性值（&lt;code>purchase.complete&lt;/code> 且 &lt;code>status = success&lt;/code>）。&lt;/p>
&lt;h2 id="過度收集的成本">過度收集的成本&lt;/h2>
&lt;p>行為事件收集的邊界是「能回答已知的分析問題」。收集超出分析需求的事件有三個成本：&lt;/p>
&lt;p>&lt;strong>儲存成本&lt;/strong>：每個事件佔一行 JSONL。高頻事件（每次滾動、每次 hover）的資料量遠大於低頻事件（按鈕點擊、頁面瀏覽）。&lt;/p>
&lt;p>&lt;strong>隱私風險&lt;/strong>：收集的事件越多，包含可識別個人行為模式的風險越高（&lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">模組七 資安與隱私&lt;/a>）。&lt;/p>
&lt;p>&lt;strong>噪音&lt;/strong>：分析時需要從大量事件中篩選出有意義的模式。事件越多，訊噪比越低。&lt;/p>
&lt;p>設計好的行為事件直接成為 &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="使用者在哪一步流失 — 從事件序列計算每步轉換率、找出流失最嚴重的步驟、區分設計問題和技術問題">Funnel analysis&lt;/a> 的輸入 — funnel 的每一步對應一個行為事件。行為事件在四類事件分類中屬於 Event 類，完整的分類定義見&lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/four-event-types/" data-link-title="四類事件的完整定義" data-link-desc="Event / Error / Metric / Lifecycle 四類事件各自的語意、觸發時機和典型用途 — 分類是監控體系的統一語言">模組一 四類事件定義&lt;/a>。收集行為事件前必須完成&lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">去識別化&lt;/a> — 使用者行為模式本身就是可識別資訊。&lt;/p></description><content:encoded><![CDATA[<p>行為事件是使用者操作的結構化記錄，每一筆事件回答「誰、在什麼時候、做了什麼、結果如何」。行為分析的品質上限由事件設計決定 — 事件粒度太粗無法回答細節問題，事件粒度太細讓儲存和查詢成本失控。</p>
<h2 id="事件命名">事件命名</h2>
<p>行為事件的命名遵循 <code>namespace.action</code> 格式（<a href="/blog/monitoring/01-mental-model/event-naming-convention/" data-link-title="事件命名規範" data-link-desc="namespace.action 格式的事件命名、命名一致性的工程價值、和商業方案命名慣例的對應">模組一 事件命名規範</a>）。行為分析場景對命名的額外要求是：同一個 funnel 內的事件要能用 namespace 前綴篩選。</p>
<p>例：註冊流程的事件用共同前綴 <code>signup</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">signup.page.view          使用者看到註冊頁
</span></span><span class="line"><span class="ln">2</span><span class="cl">signup.form.submit        使用者送出表單
</span></span><span class="line"><span class="ln">3</span><span class="cl">signup.email.verify       使用者點擊驗證信連結
</span></span><span class="line"><span class="ln">4</span><span class="cl">signup.complete           註冊完成</span></span></code></pre></div><p>用 <code>signup.*</code> 就能篩選出整個註冊流程的事件，不需要事先知道每一步的完整名稱。</p>
<h2 id="屬性設計">屬性設計</h2>
<p>每個事件除了名稱，還帶有屬性（properties / parameters）描述事件的 context。屬性分成三層：</p>
<h3 id="通用屬性每個事件都有">通用屬性（每個事件都有）</h3>
<ul>
<li><code>timestamp</code>：事件發生的時間（UTC，毫秒精度）</li>
<li><code>session_id</code>：當次使用的 session 識別碼</li>
<li><code>user_id</code>：使用者識別碼（去識別化後，見 <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">模組七</a>）</li>
<li><code>platform</code>：iOS / Android / Web</li>
<li><code>app_version</code>：app 版本號</li>
</ul>
<h3 id="事件類型屬性同類事件共有">事件類型屬性（同類事件共有）</h3>
<ul>
<li>頁面瀏覽事件：<code>page_name</code>、<code>referrer</code></li>
<li>按鈕點擊事件：<code>button_id</code>、<code>button_text</code></li>
<li>搜尋事件：<code>query</code>、<code>result_count</code></li>
</ul>
<h3 id="事件專屬屬性特定事件才有">事件專屬屬性（特定事件才有）</h3>
<ul>
<li><code>signup.form.submit</code>：<code>form_method</code>（email / Google / Apple）</li>
<li><code>purchase.complete</code>：<code>amount</code>、<code>currency</code>、<code>product_id</code></li>
</ul>
<p>屬性設計的判斷標準是：這個屬性是否用於回答一個分析問題。「註冊方式的轉換率差異」需要 <code>form_method</code> 屬性；如果沒有這個分析問題，就不需要這個屬性。</p>
<h2 id="funnel-定義">Funnel 定義</h2>
<p>Funnel 是一連串有順序的事件，代表使用者完成一個目標的步驟。Funnel 定義在事件設計階段完成 — 決定哪些事件構成一個 funnel、順序是什麼、每步之間的最大時間間隔。</p>
<p>定義一個 funnel 需要：</p>
<p><strong>步驟清單</strong>：funnel 包含哪些事件，順序是什麼。</p>
<p><strong>時間窗口</strong>：步驟之間的最大間隔。使用者在步驟 A 之後 30 天才做步驟 B，是否算在同一個 funnel 內？時間窗口的設定取決於業務場景 — 電商結帳 funnel 通常是 30 分鐘，SaaS onboarding funnel 可能是 7 天。</p>
<p><strong>完成條件</strong>：什麼算「完成」funnel。到達最後一步即完成，還是需要特定屬性值（<code>purchase.complete</code> 且 <code>status = success</code>）。</p>
<h2 id="過度收集的成本">過度收集的成本</h2>
<p>行為事件收集的邊界是「能回答已知的分析問題」。收集超出分析需求的事件有三個成本：</p>
<p><strong>儲存成本</strong>：每個事件佔一行 JSONL。高頻事件（每次滾動、每次 hover）的資料量遠大於低頻事件（按鈕點擊、頁面瀏覽）。</p>
<p><strong>隱私風險</strong>：收集的事件越多，包含可識別個人行為模式的風險越高（<a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">模組七 資安與隱私</a>）。</p>
<p><strong>噪音</strong>：分析時需要從大量事件中篩選出有意義的模式。事件越多，訊噪比越低。</p>
<p>設計好的行為事件直接成為 <a href="/blog/monitoring/08-business-analytics/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="使用者在哪一步流失 — 從事件序列計算每步轉換率、找出流失最嚴重的步驟、區分設計問題和技術問題">Funnel analysis</a> 的輸入 — funnel 的每一步對應一個行為事件。行為事件在四類事件分類中屬於 Event 類，完整的分類定義見<a href="/blog/monitoring/01-mental-model/four-event-types/" data-link-title="四類事件的完整定義" data-link-desc="Event / Error / Metric / Lifecycle 四類事件各自的語意、觸發時機和典型用途 — 分類是監控體系的統一語言">模組一 四類事件定義</a>。收集行為事件前必須完成<a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">去識別化</a> — 使用者行為模式本身就是可識別資訊。</p>
]]></content:encoded></item><item><title>模組一：監控心智模型</title><link>https://tarrragon.github.io/blog/monitoring/01-mental-model/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/01-mental-model/</guid><description>&lt;p>回答「要收集什麼、為什麼」。四類事件分類是整個監控體系的統一語言。&lt;/p>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 四類事件的完整定義（event / error / metric / lifecycle）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 事件命名規範（namespace.action 格式）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 商業方案的事件類型對應（Sentry / Crashlytics / GA4 / Datadog RUM）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 從需求推導「該收集哪些事件」的方法&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &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>：本模組教分類，testing 教設計 log 點&lt;/li>
&lt;li>→ &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 工具到商業資產的翻轉">monitoring 模組八 商業利用&lt;/a>：event 類是行為分析的原料&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">backend 04 可觀測性&lt;/a>：server-side 用 OTLP，本系列用 HTTP POST JSON&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「要收集什麼、為什麼」。四類事件分類是整個監控體系的統一語言。</p>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> 四類事件的完整定義（event / error / metric / lifecycle）</li>
<li><input checked="" disabled="" type="checkbox"> 事件命名規範（namespace.action 格式）</li>
<li><input checked="" disabled="" type="checkbox"> 商業方案的事件類型對應（Sentry / Crashlytics / GA4 / Datadog RUM）</li>
<li><input checked="" disabled="" type="checkbox"> 從需求推導「該收集哪些事件」的方法</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/testing/02-client-observability/" data-link-title="模組二：客戶端可觀測性" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — log 設計是功能規格的一部分">testing 模組二 客戶端可觀測性</a>：本模組教分類，testing 教設計 log 點</li>
<li>→ <a href="/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">monitoring 模組八 商業利用</a>：event 類是行為分析的原料</li>
<li>→ <a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">backend 04 可觀測性</a>：server-side 用 OTLP，本系列用 HTTP POST JSON</li>
</ul>
]]></content:encoded></item><item><title>Refusal Rate</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/refusal-rate/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/refusal-rate/</guid><description>&lt;p>Refusal rate 的核心概念是「LLM 拒絕回答 prompt 的比例」。LLM 在訓練階段（特別是 RLHF）會學到「對特定類型的請求說『我不能幫忙這個』」、production 服務通常會監控這個比例作為對齊強度跟異常行為偵測的訊號之一。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Refusal 行為的典型形態：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>形態&lt;/th>
 &lt;th>例子&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>安全相關拒絕&lt;/td>
 &lt;td>&amp;ldquo;Sorry, I can&amp;rsquo;t help with that request.&amp;rdquo;&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>政策相關拒絕&lt;/td>
 &lt;td>&amp;ldquo;I&amp;rsquo;m not able to discuss specific medical advice.&amp;rdquo;&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>能力相關拒絕&lt;/td>
 &lt;td>&amp;ldquo;I don&amp;rsquo;t have real-time data access.&amp;rdquo;&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>模糊拒絕（soft refusal）&lt;/td>
 &lt;td>&amp;ldquo;That&amp;rsquo;s an interesting question, but&amp;hellip;&amp;rdquo;&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Refusal rate 作為偵測訊號的兩個方向：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>率突然下降&lt;/strong>：可能是對齊被繞過、&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/prompt-injection/" data-link-title="Prompt Injection" data-link-desc="把惡意指令藏進 LLM 會讀到的內容、誘導 LLM 跑出非開發者預期行為的攻擊類別、OWASP LLM01 列入頭號威脅">prompt injection&lt;/a> 攻擊在進行、或新版本模型對齊變弱。&lt;/li>
&lt;li>&lt;strong>率突然上升&lt;/strong>：可能是訓練資料或對齊政策變嚴、影響使用者體驗、或 vendor 端政策調整。&lt;/li>
&lt;/ol>
&lt;p>實作上、偵測 refusal 通常用簡單 pattern matching（看是否含 &amp;ldquo;I can&amp;rsquo;t&amp;rdquo; / &amp;ldquo;I&amp;rsquo;m not able&amp;rdquo; / &amp;ldquo;Sorry&amp;rdquo; 等）或更精確的 classifier；具體實作依 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/detection-coverage-and-signal-governance/" data-link-title="7.13 偵測覆蓋率與訊號治理" data-link-desc="定義偵測覆蓋、訊號品質與誤報成本的治理問題">偵測平台&lt;/a> 設計。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>事實查核註&lt;/strong>：refusal rate 的標準化測量方式、跟「對齊強度」的對應關係仍在研究演進、不同 vendor 跟 model 的 baseline 差異大、引用前以對應模型的 model card 跟最新研究為準。&lt;/p>&lt;/blockquote>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>理解 refusal rate 後可以解釋兩個現象：為什麼 production LLM 服務監控 refusal rate（變化是異常訊號）、為什麼開源模型的 refusal rate 通常低於商業旗艦（前者 safety RLHF 投入較少）。&lt;/p>
&lt;p>production 設計時、refusal rate 是 content 層偵測訊號之一、需配合 tool call 序列、token usage、prompt pattern 等其他訊號才能形成完整偵測覆蓋。詳見 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/llm-as-service-detection-coverage/" data-link-title="LLM Service 偵測訊號覆蓋" data-link-desc="production LLM 服務的 detection 訊號設計：tool call 異常模式、prompt injection 觸發徵兆、abuse 跟濫用模式、跟既有 detection-coverage 框架的接合">LLM Service 偵測訊號覆蓋&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Refusal rate 的核心概念是「LLM 拒絕回答 prompt 的比例」。LLM 在訓練階段（特別是 RLHF）會學到「對特定類型的請求說『我不能幫忙這個』」、production 服務通常會監控這個比例作為對齊強度跟異常行為偵測的訊號之一。</p>
<h2 id="概念位置">概念位置</h2>
<p>Refusal 行為的典型形態：</p>
<table>
  <thead>
      <tr>
          <th>形態</th>
          <th>例子</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>安全相關拒絕</td>
          <td>&ldquo;Sorry, I can&rsquo;t help with that request.&rdquo;</td>
      </tr>
      <tr>
          <td>政策相關拒絕</td>
          <td>&ldquo;I&rsquo;m not able to discuss specific medical advice.&rdquo;</td>
      </tr>
      <tr>
          <td>能力相關拒絕</td>
          <td>&ldquo;I don&rsquo;t have real-time data access.&rdquo;</td>
      </tr>
      <tr>
          <td>模糊拒絕（soft refusal）</td>
          <td>&ldquo;That&rsquo;s an interesting question, but&hellip;&rdquo;</td>
      </tr>
  </tbody>
</table>
<p>Refusal rate 作為偵測訊號的兩個方向：</p>
<ol>
<li><strong>率突然下降</strong>：可能是對齊被繞過、<a href="/blog/llm/knowledge-cards/prompt-injection/" data-link-title="Prompt Injection" data-link-desc="把惡意指令藏進 LLM 會讀到的內容、誘導 LLM 跑出非開發者預期行為的攻擊類別、OWASP LLM01 列入頭號威脅">prompt injection</a> 攻擊在進行、或新版本模型對齊變弱。</li>
<li><strong>率突然上升</strong>：可能是訓練資料或對齊政策變嚴、影響使用者體驗、或 vendor 端政策調整。</li>
</ol>
<p>實作上、偵測 refusal 通常用簡單 pattern matching（看是否含 &ldquo;I can&rsquo;t&rdquo; / &ldquo;I&rsquo;m not able&rdquo; / &ldquo;Sorry&rdquo; 等）或更精確的 classifier；具體實作依 <a href="/blog/backend/07-security-data-protection/detection-coverage-and-signal-governance/" data-link-title="7.13 偵測覆蓋率與訊號治理" data-link-desc="定義偵測覆蓋、訊號品質與誤報成本的治理問題">偵測平台</a> 設計。</p>
<blockquote>
<p><strong>事實查核註</strong>：refusal rate 的標準化測量方式、跟「對齊強度」的對應關係仍在研究演進、不同 vendor 跟 model 的 baseline 差異大、引用前以對應模型的 model card 跟最新研究為準。</p></blockquote>
<h2 id="設計責任">設計責任</h2>
<p>理解 refusal rate 後可以解釋兩個現象：為什麼 production LLM 服務監控 refusal rate（變化是異常訊號）、為什麼開源模型的 refusal rate 通常低於商業旗艦（前者 safety RLHF 投入較少）。</p>
<p>production 設計時、refusal rate 是 content 層偵測訊號之一、需配合 tool call 序列、token usage、prompt pattern 等其他訊號才能形成完整偵測覆蓋。詳見 <a href="/blog/backend/07-security-data-protection/llm-as-service-detection-coverage/" data-link-title="LLM Service 偵測訊號覆蓋" data-link-desc="production LLM 服務的 detection 訊號設計：tool call 異常模式、prompt injection 觸發徵兆、abuse 跟濫用模式、跟既有 detection-coverage 框架的接合">LLM Service 偵測訊號覆蓋</a>。</p>
]]></content:encoded></item><item><title>部署光譜：從 BaaS 到自架的四條路徑</title><link>https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/deployment-spectrum/</link><pubDate>Wed, 24 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/deployment-spectrum/</guid><description>&lt;p>監控方案的選擇不是「完全自架 Go collector」和「買 Sentry 訂閱」的二元決策。中間存在兩條路徑 — 用 BaaS（Supabase / Firebase）搭出託管版 collector，或用 PaaS（Railway / Fly.io）跑自架 collector 原始碼但不管 server。四條路徑的本質差異在「哪些層自己管、哪些交給平台」。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/self-hosted-vs-commercial/" data-link-title="自架 vs 商業的判斷決策表" data-link-desc="使用者數、網路範圍、功能需求、合規要求四個維度判斷該自架還是用商業方案">自架 vs 商業的判斷決策表&lt;/a>用四個維度（使用者數 / 網路範圍 / 功能需求 / 合規）做二元分流。本章把光譜展開成四條路徑，讓中間的 BaaS 和 PaaS 選項浮現。Backend 選型模組已建立了完整的交付形態光譜（&lt;a href="https://tarrragon.github.io/blog/backend/00-service-selection/delivery-mode-selection/" data-link-title="0.21 交付形態選型：從全託管到自建的光譜與邊界" data-link-desc="在進入資料庫、快取與部署選型之前、先判斷服務該用託管平台（Wix / Shopify / Google Sites）、辦公生態自動化（Apps Script）、BaaS（Firebase）、半託管 CMS（WordPress）還是自建、並為日後遷往自建保留可遷出路徑">交付形態選型&lt;/a>）和逐能力判斷外包深度的框架（&lt;a href="https://tarrragon.github.io/blog/backend/00-service-selection/capability-buy-vs-build/" data-link-title="0.22 能力級買 vs 建：feature-as-a-service 與 BaaS bundle 選型" data-link-desc="在交付形態決定整個系統要不要自建之後、逐能力判斷該外包還是自建：辨識 managed 基礎設施、feature SaaS 與 BaaS bundle 三種外包深度、no-code 到 dev-tool 的服務光譜、買 vs 建判準與權重浮動、整合接縫與遷出代價">能力級買 vs 建&lt;/a>）。本章把那個框架特化到監控場景。&lt;/p>
&lt;h2 id="四條路徑">四條路徑&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>路徑&lt;/th>
 &lt;th>代表方案&lt;/th>
 &lt;th>Collector 是什麼&lt;/th>
 &lt;th>Storage 是什麼&lt;/th>
 &lt;th>自己管什麼&lt;/th>
 &lt;th>平台管什麼&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>A. 商業監控 SaaS&lt;/td>
 &lt;td>Sentry / Datadog / Firebase Analytics&lt;/td>
 &lt;td>vendor 提供&lt;/td>
 &lt;td>vendor 提供&lt;/td>
 &lt;td>SDK 埋點&lt;/td>
 &lt;td>全部&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>B. BaaS + Serverless&lt;/td>
 &lt;td>Supabase + Vercel / Cloudflare Workers&lt;/td>
 &lt;td>serverless function（自己寫）&lt;/td>
 &lt;td>managed PostgreSQL（Supabase）&lt;/td>
 &lt;td>collector 邏輯、schema&lt;/td>
 &lt;td>server 維運、DB 維運、TLS、HA&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>C. PaaS&lt;/td>
 &lt;td>Railway / Fly.io / Render&lt;/td>
 &lt;td>Go binary（自架 collector 原始碼）&lt;/td>
 &lt;td>SQLite（同 binary）或 managed DB&lt;/td>
 &lt;td>collector 邏輯、storage&lt;/td>
 &lt;td>server 維運、TLS、deploy&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>D. 完全自架&lt;/td>
 &lt;td>VPS + Go binary&lt;/td>
 &lt;td>Go binary&lt;/td>
 &lt;td>SQLite 或自管 PostgreSQL&lt;/td>
 &lt;td>全部&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>路徑 A 和 D 分別是光譜的兩端 — &lt;a href="https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/sentry-deep-dive/" data-link-title="Sentry 深入" data-link-desc="Error tracking &amp;#43; performance monitoring &amp;#43; session replay 的架構 — Sentry 從 error-first 出發如何擴展到全面可觀測性">Sentry 深入&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/firebase-suite/" data-link-title="Firebase 套件" data-link-desc="Crashlytics &amp;#43; Analytics &amp;#43; Remote Config 的整合 — Firebase 把 error tracking 和行為分析拆成獨立產品的設計取捨">Firebase 套件&lt;/a>和&lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計&lt;/a>已完整討論。以下展開路徑 B 和 C。&lt;/p></description><content:encoded><![CDATA[<p>監控方案的選擇不是「完全自架 Go collector」和「買 Sentry 訂閱」的二元決策。中間存在兩條路徑 — 用 BaaS（Supabase / Firebase）搭出託管版 collector，或用 PaaS（Railway / Fly.io）跑自架 collector 原始碼但不管 server。四條路徑的本質差異在「哪些層自己管、哪些交給平台」。</p>
<p><a href="/blog/monitoring/06-commercial-comparison/self-hosted-vs-commercial/" data-link-title="自架 vs 商業的判斷決策表" data-link-desc="使用者數、網路範圍、功能需求、合規要求四個維度判斷該自架還是用商業方案">自架 vs 商業的判斷決策表</a>用四個維度（使用者數 / 網路範圍 / 功能需求 / 合規）做二元分流。本章把光譜展開成四條路徑，讓中間的 BaaS 和 PaaS 選項浮現。Backend 選型模組已建立了完整的交付形態光譜（<a href="/blog/backend/00-service-selection/delivery-mode-selection/" data-link-title="0.21 交付形態選型：從全託管到自建的光譜與邊界" data-link-desc="在進入資料庫、快取與部署選型之前、先判斷服務該用託管平台（Wix / Shopify / Google Sites）、辦公生態自動化（Apps Script）、BaaS（Firebase）、半託管 CMS（WordPress）還是自建、並為日後遷往自建保留可遷出路徑">交付形態選型</a>）和逐能力判斷外包深度的框架（<a href="/blog/backend/00-service-selection/capability-buy-vs-build/" data-link-title="0.22 能力級買 vs 建：feature-as-a-service 與 BaaS bundle 選型" data-link-desc="在交付形態決定整個系統要不要自建之後、逐能力判斷該外包還是自建：辨識 managed 基礎設施、feature SaaS 與 BaaS bundle 三種外包深度、no-code 到 dev-tool 的服務光譜、買 vs 建判準與權重浮動、整合接縫與遷出代價">能力級買 vs 建</a>）。本章把那個框架特化到監控場景。</p>
<h2 id="四條路徑">四條路徑</h2>
<table>
  <thead>
      <tr>
          <th>路徑</th>
          <th>代表方案</th>
          <th>Collector 是什麼</th>
          <th>Storage 是什麼</th>
          <th>自己管什麼</th>
          <th>平台管什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A. 商業監控 SaaS</td>
          <td>Sentry / Datadog / Firebase Analytics</td>
          <td>vendor 提供</td>
          <td>vendor 提供</td>
          <td>SDK 埋點</td>
          <td>全部</td>
      </tr>
      <tr>
          <td>B. BaaS + Serverless</td>
          <td>Supabase + Vercel / Cloudflare Workers</td>
          <td>serverless function（自己寫）</td>
          <td>managed PostgreSQL（Supabase）</td>
          <td>collector 邏輯、schema</td>
          <td>server 維運、DB 維運、TLS、HA</td>
      </tr>
      <tr>
          <td>C. PaaS</td>
          <td>Railway / Fly.io / Render</td>
          <td>Go binary（自架 collector 原始碼）</td>
          <td>SQLite（同 binary）或 managed DB</td>
          <td>collector 邏輯、storage</td>
          <td>server 維運、TLS、deploy</td>
      </tr>
      <tr>
          <td>D. 完全自架</td>
          <td>VPS + Go binary</td>
          <td>Go binary</td>
          <td>SQLite 或自管 PostgreSQL</td>
          <td>全部</td>
          <td>無</td>
      </tr>
  </tbody>
</table>
<p>路徑 A 和 D 分別是光譜的兩端 — <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>、<a href="/blog/monitoring/06-commercial-comparison/firebase-suite/" data-link-title="Firebase 套件" data-link-desc="Crashlytics &#43; Analytics &#43; Remote Config 的整合 — Firebase 把 error tracking 和行為分析拆成獨立產品的設計取捨">Firebase 套件</a>和<a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計</a>已完整討論。以下展開路徑 B 和 C。</p>
<h2 id="路徑-bbaas--serverless">路徑 B：BaaS + Serverless</h2>
<p>APP 上線初期用 Supabase + Vercel（或 Cloudflare Workers）搭監控後端：serverless function 接收 SDK 送來的事件、驗證 schema 後寫入 Supabase 的 PostgreSQL。整條鏈路在免費方案額度內可以零成本運作。</p>
<h3 id="架構差異">架構差異</h3>
<p>Serverless function 沒有常駐 process。模組四假設的 Go single binary 架構 — channel 背壓、single-writer goroutine pattern、in-memory buffer — 在 serverless 環境都不適用。每個 HTTP request 是獨立的 function invocation，沒有跨 request 的記憶體狀態。</p>
<p>背壓機制需要重新設計：Go collector 用 channel 容量做背壓（channel 滿回 429），serverless 版改用 DB-level 的 rate limit（PostgreSQL 的 advisory lock 或外部 rate limiter 如 Upstash Redis）或 platform-level 的 quota（Vercel 的 concurrency limit）。SDK 端的 429 處理邏輯不需要改 — 不管背壓訊號來自 channel 還是 DB quota，SDK 都是收到 429 後降採樣。</p>
<p>Downsample 和 purge 在 Go collector 是 background goroutine 定期執行。Serverless 沒有 background job — 需要外部 cron trigger（Vercel Cron / Supabase pg_cron / GitHub Actions scheduled workflow）。</p>
<h3 id="免費方案限額">免費方案限額</h3>
<p>以下為 2026-06 查詢的各平台免費方案限額。平台定價會變動，決策前以官方定價頁為準。</p>
<table>
  <thead>
      <tr>
          <th>平台</th>
          <th>免費方案限額</th>
          <th>對監控場景的意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Supabase Free</td>
          <td>500MB DB、50K MAU、500K Edge Function invocations/月</td>
          <td>500MB 約 50-100 萬筆事件（每筆 ~500 bytes）、自用場景可用數月</td>
      </tr>
      <tr>
          <td>Vercel Hobby</td>
          <td>100GB bandwidth、10s function timeout、無明確 invocation 上限</td>
          <td>瓶頸在 bandwidth 和 execution duration、非 invocation 數；timeout 對 ingestion 足夠</td>
      </tr>
      <tr>
          <td>Cloudflare Workers</td>
          <td>100K requests/天（免費）、D1 5GB</td>
          <td>100K requests/天 x 100 筆/batch = 10M events/天、D1 的 SQLite 可替代 Supabase</td>
      </tr>
  </tbody>
</table>
<p>Audit date: 2026-06。平台免費方案限額可能調整，決策前以官方定價頁為準。</p>
<h3 id="適合情境">適合情境</h3>
<p>路徑 B 適合以下組合：APP 上線初期（使用者數 &lt; 100）、團隊熟悉前端和 SQL 但不想管 server、想保留自訂 schema 和查詢彈性（商業 SaaS 的 schema 是 vendor 定義的）、零成本起步但未來可能遷到自架。</p>
<h3 id="撞牆訊號">撞牆訊號</h3>
<p>以下訊號出現時，代表路徑 B 的天花板已到、該評估遷到路徑 C 或 D：</p>
<p><strong>連線數瓶頸</strong>：Supabase Free 的 PostgreSQL 約 20 個 concurrent connection。Serverless function 每次 invocation 開新連線，高併發時可能耗盡連線池。Supabase 內建 PgBouncer 做 connection pooling 可緩解，但免費方案的 pooler 有自己的連線上限。</p>
<p><strong>Cold start 延遲</strong>：Vercel serverless function 的 cold start 約 200ms、Supabase Edge Function 約 100ms。對監控 ingestion（不是使用者面向 API）通常可接受，但如果 SDK 的 flush timeout 設得很短（&lt; 1s），cold start 可能造成偶發超時。</p>
<p><strong>Background job 限制</strong>：Downsample 和 purge 需要外部 cron。Vercel Hobby 支援最多 2 個 cron job、每個最頻繁每天觸發 1 次 — 如果需要每小時 downsample，要用 Supabase pg_cron（Free 方案支援）或外部 scheduler。</p>
<p><strong>免費額度耗盡</strong>：Supabase 的 500K Edge Function invocations/月 ≈ 每天 16K requests。如果每個 request 攢批 100 筆事件，可處理每天 160 萬筆事件。超過後進入按量付費。Vercel Hobby 無明確 invocation 上限、瓶頸在 bandwidth（100GB/月）和 execution duration。</p>
<p><strong>合規限制</strong>：Supabase Free 的 PostgreSQL 部署在特定 region。有 GDPR data residency 需求的 app（歐盟使用者的資料必須留在 EU）需確認 vendor 的 region 支援 — 免費方案的 region 選擇可能有限。</p>
<h2 id="路徑-cpaas">路徑 C：PaaS</h2>
<p>PaaS 跑的是和完全自架相同的 Go collector 原始碼，差異只在部署方式。<code>git push</code> 觸發自動 build 和 deploy，平台管 server provisioning、TLS 憑證、process supervision。Collector 的 channel 背壓、single-writer pattern、SQLite storage 全部適用 — 和本機開發環境的行為一致。</p>
<p>Railway 和 Fly.io 都支援 persistent volume — Railway Hobby 含 1GB、Fly.io Free 含 1GB（限單 region）。SQLite 的 WAL 檔案需要持久化，persistent volume 是必要條件。Render 的免費方案沒有 persistent disk — SQLite 在每次 deploy 後重置，不適合需要保留歷史事件的場景。PaaS 平台以 container 形式運行 collector，SQLite 在 container 中的 I/O 和持久化考量見 <a href="/blog/monitoring/04-collector/container-deployment/" data-link-title="Container 部署設計" data-link-desc="Docker 部署 collector 的設計 — SQLite 在 overlay filesystem 的 I/O 考量、volume mount、graceful shutdown、資源限制">Container 部署設計</a>。</p>
<p>路徑 C 適合：想用自架 collector 但不想管 server / TLS / systemd 的團隊。程式碼完全相同，遷到自架（路徑 D）的成本接近零 — 把 binary 複製到 VPS、設定 systemd service 就完成。</p>
<p>路徑 C 的天花板在平台定價 — Railway Hobby 有 $5/月的資源上限、Fly.io Free 有 3 個 shared VM。流量成長到免費額度不夠時，PaaS 的按量付費和 VPS 月租費的交叉點是遷到自架的判讀訊號。</p>
<h2 id="路徑間的遷移">路徑間的遷移</h2>
<p>遷移成本取決於起點和終點之間有多少層需要重寫。</p>
<table>
  <thead>
      <tr>
          <th>遷移方向</th>
          <th>成本</th>
          <th>主要工作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>B → C</td>
          <td>中</td>
          <td>Serverless function → Go binary（重寫 collector 邏輯）；DB 可保留或遷移</td>
      </tr>
      <tr>
          <td>B → D</td>
          <td>中</td>
          <td>同上 + 自己管 server</td>
      </tr>
      <tr>
          <td>C → D</td>
          <td>低</td>
          <td>同程式碼不同部署（複製 binary + systemd）</td>
      </tr>
      <tr>
          <td>D → C</td>
          <td>低</td>
          <td>同程式碼推到 PaaS</td>
      </tr>
      <tr>
          <td>D → A</td>
          <td>低</td>
          <td>SDK 改 endpoint 指向商業方案、不改 SDK 程式碼</td>
      </tr>
      <tr>
          <td>A → D</td>
          <td>高</td>
          <td>從零建 collector + storage + dashboard</td>
      </tr>
      <tr>
          <td>A → B</td>
          <td>高</td>
          <td>從零寫 serverless collector + 設定 managed DB</td>
      </tr>
      <tr>
          <td>A → C</td>
          <td>高</td>
          <td>從零寫 Go collector + 推到 PaaS</td>
      </tr>
  </tbody>
</table>
<p>路徑 B → C 或 B → D 的遷移代價主要在 collector 邏輯的重寫 — serverless function 的 request-level 處理和 Go binary 的 channel-based pipeline 是不同的架構，不能直接搬。資料層的遷移代價較低 — Supabase 的 PostgreSQL 資料可以用 <code>pg_dump</code> 匯出、匯入自管 PostgreSQL。</p>
<p>交付形態遷出的通用框架（資產線盤點、並行期設計、回切窗口）見 <a href="/blog/backend/10-system-evolution/managed-platform-exit/" data-link-title="10.3 託管形態遷出：資產線盤點與並行期執行" data-link-desc="0.21 升級自建 tripwire 觸發後的執行劇本 — 把遷出拆成資料、身分、流量、整合各自的可攜性與斷點、設計舊平台與新系統的並行期與回切窗口、用部分遷出作為中繼形態">託管形態遷出</a>。</p>
<h2 id="外包深度對照">外包深度對照</h2>
<p>用 <a href="/blog/backend/knowledge-cards/capability-outsourcing-depth/" data-link-title="Capability Outsourcing Depth（外包深度）" data-link-desc="說明外包一塊後端能力有三種深度（managed 基礎設施、feature SaaS、BaaS bundle）、深度決定保留多少控制權與遷出代價">外包深度</a> 的三層框架（managed 基礎設施 / feature SaaS / BaaS bundle）看四條路徑：</p>
<table>
  <thead>
      <tr>
          <th>路徑</th>
          <th>外包深度</th>
          <th>控制權</th>
          <th>遷出代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A. 商業監控 SaaS</td>
          <td>feature SaaS（最深）</td>
          <td>SDK 埋點 API、vendor 定義 schema 和查詢</td>
          <td>高</td>
      </tr>
      <tr>
          <td>B. BaaS + Serverless</td>
          <td>managed 基礎設施 + 自寫 function（中間）</td>
          <td>自訂 schema、自訂查詢、自訂 collector 邏輯</td>
          <td>中</td>
      </tr>
      <tr>
          <td>C. PaaS</td>
          <td>managed 基礎設施（淺）</td>
          <td>和自架相同、只有部署平台交出去</td>
          <td>低</td>
      </tr>
      <tr>
          <td>D. 完全自架</td>
          <td>不外包</td>
          <td>完全控制</td>
          <td>無</td>
      </tr>
  </tbody>
</table>
<p>路徑 B 在外包深度上介於 managed 基礎設施和 BaaS bundle 之間 — DB 和 runtime 交給平台，但 collector 邏輯和 schema 仍由開發者控制。這和 <a href="/blog/backend/knowledge-cards/baas/" data-link-title="BaaS（Backend as a Service）" data-link-desc="說明把認證、資料庫、檔案儲存、推播打包成現成模組、由前端 SDK 直連的後端交付形態">BaaS</a> 的「前端 SDK 直連平台資料庫」模式不同 — 監控場景的路徑 B 仍然有一個自己寫的中間層（serverless function），只是這個中間層跑在平台上而非自己的 server。</p>
<h2 id="選擇建議">選擇建議</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>建議路徑</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自用工具、同機或同網段</td>
          <td>D</td>
          <td>成本最低、複雜度最低</td>
      </tr>
      <tr>
          <td>APP 上線初期、使用者 &lt; 100、零成本起步</td>
          <td>B 或 A</td>
          <td>B 保留自訂彈性、A 開箱即用</td>
      </tr>
      <tr>
          <td>小型團隊、想用自架 collector 但不想管 server</td>
          <td>C</td>
          <td>程式碼相同、部署簡單、遷出成本低</td>
      </tr>
      <tr>
          <td>使用者 &gt; 1000、需要 dashboard + 告警 + replay</td>
          <td>A</td>
          <td>商業方案的功能完成度遠高於自建</td>
      </tr>
      <tr>
          <td>合規要求資料不離開自有設施</td>
          <td>D</td>
          <td>完全控制資料位置</td>
      </tr>
  </tbody>
</table>
<p>APP 上線初期選 B 或 A 取決於自訂需求 — 需要自訂 schema 和查詢邏輯（例如自定義 error fingerprint、行為事件命名規範）選 B，只需要開箱即用的 error tracking 或行為分析選 A。B 保留遷到自架的彈性（資料在自己的 PostgreSQL），A 的功能完成度更高（dashboard、告警、session replay 開箱即用）。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>自架 vs 商業的詳細決策 → <a href="/blog/monitoring/06-commercial-comparison/self-hosted-vs-commercial/" data-link-title="自架 vs 商業的判斷決策表" data-link-desc="使用者數、網路範圍、功能需求、合規要求四個維度判斷該自架還是用商業方案">自架 vs 商業的判斷決策表</a></li>
<li>自架 collector 的完整設計 → <a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計</a></li>
<li>Backend 交付形態光譜 → <a href="/blog/backend/00-service-selection/delivery-mode-selection/" data-link-title="0.21 交付形態選型：從全託管到自建的光譜與邊界" data-link-desc="在進入資料庫、快取與部署選型之前、先判斷服務該用託管平台（Wix / Shopify / Google Sites）、辦公生態自動化（Apps Script）、BaaS（Firebase）、半託管 CMS（WordPress）還是自建、並為日後遷往自建保留可遷出路徑">交付形態選型</a></li>
<li>能力級買 vs 建判斷 → <a href="/blog/backend/00-service-selection/capability-buy-vs-build/" data-link-title="0.22 能力級買 vs 建：feature-as-a-service 與 BaaS bundle 選型" data-link-desc="在交付形態決定整個系統要不要自建之後、逐能力判斷該外包還是自建：辨識 managed 基礎設施、feature SaaS 與 BaaS bundle 三種外包深度、no-code 到 dev-tool 的服務光譜、買 vs 建判準與權重浮動、整合接縫與遷出代價">能力級買 vs 建</a></li>
<li>外包深度概念 → <a href="/blog/backend/knowledge-cards/capability-outsourcing-depth/" data-link-title="Capability Outsourcing Depth（外包深度）" data-link-desc="說明外包一塊後端能力有三種深度（managed 基礎設施、feature SaaS、BaaS bundle）、深度決定保留多少控制權與遷出代價">外包深度</a></li>
<li>BaaS 概念 → <a href="/blog/backend/knowledge-cards/baas/" data-link-title="BaaS（Backend as a Service）" data-link-desc="說明把認證、資料庫、檔案儲存、推播打包成現成模組、由前端 SDK 直連的後端交付形態">BaaS</a></li>
<li>遷出劇本 → <a href="/blog/backend/10-system-evolution/managed-platform-exit/" data-link-title="10.3 託管形態遷出：資產線盤點與並行期執行" data-link-desc="0.21 升級自建 tripwire 觸發後的執行劇本 — 把遷出拆成資料、身分、流量、整合各自的可攜性與斷點、設計舊平台與新系統的並行期與回切窗口、用部分遷出作為中繼形態">託管形態遷出</a></li>
<li>Vendor lock-in 概念 → <a href="/blog/backend/knowledge-cards/vendor-lock-in/" data-link-title="Vendor Lock-In" data-link-desc="說明採用供應商產品後，其 API 與格式滲入程式碼造成的退出成本">Vendor Lock-In</a></li>
</ul>
]]></content:encoded></item><item><title>Flutter 平台適配</title><link>https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/flutter-platform/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/flutter-platform/</guid><description>&lt;p>Flutter 應用程式在 Dart VM 中執行，有自己的執行緒模型（Isolate）、原生平台橋接（Platform channel）和 app 生命週期管理。監控 SDK 在 Flutter 中需要處理的平台特殊問題集中在這三個面向。&lt;/p>
&lt;h2 id="isolate-安全">Isolate 安全&lt;/h2>
&lt;p>Dart 的 Isolate 是獨立的記憶體空間，Isolate 之間不共享記憶體，只能透過 message passing 溝通。SDK 的記憶體 buffer 存在於 main isolate 中，其他 isolate 產生的事件需要透過 port 傳送到 main isolate 才能進入 buffer。&lt;/p>
&lt;p>SDK 端的適配：&lt;/p>
&lt;p>提供 &lt;code>Monitor.eventFromIsolate(SendPort port)&lt;/code> 方法，在子 isolate 中透過 port 把事件送回 main isolate。或者提供 isolate-aware 的 &lt;code>Monitor.init()&lt;/code> 變體，在子 isolate 中初始化一個輕量的 event forwarder。&lt;/p>
&lt;p>如果 SDK 使用 compute 或 Isolate.spawn 做背景任務（例如壓縮 buffer），需要透過 port 把結果送回 main isolate — 背景 isolate 無法直接存取 main isolate 的 HTTP client 或 buffer。&lt;/p>
&lt;h2 id="platform-channel-攔截">Platform channel 攔截&lt;/h2>
&lt;p>Flutter 透過 Platform channel 呼叫原生平台功能（iOS 的 Swift/ObjC、Android 的 Kotlin/Java）。Platform channel 的呼叫可能失敗（原生端未實作、參數格式錯誤、原生端拋出例外），這些錯誤在 Dart 端表現為 &lt;code>PlatformException&lt;/code>。&lt;/p>
&lt;p>SDK 可以攔截 Platform channel 的呼叫記錄每次呼叫的方法名稱、參數、結果和耗時。攔截方式是替換 &lt;code>ServicesBinding.defaultBinaryMessenger&lt;/code> 的處理器，在轉發前後記錄事件。&lt;/p>
&lt;p>攔截的價值是：Platform channel 的錯誤通常難以 debug（stack trace 跨越 Dart 和原生兩層），監控記錄提供「呼叫了哪個 channel method、傳了什麼參數、在哪一層失敗」的完整 context。&lt;/p>
&lt;p>注意：攔截 Platform channel 會增加每次呼叫的延遲（記錄事件的開銷）。對高頻的 Platform channel 呼叫（例如每幀都呼叫的渲染相關 channel），攔截可能影響效能。SDK 應該提供 channel 過濾機制 — 只攔截特定 channel 或只在 debug mode 攔截。&lt;/p>
&lt;h2 id="app-lifecycle-事件">App lifecycle 事件&lt;/h2>
&lt;p>Flutter 的 &lt;code>WidgetsBindingObserver&lt;/code> 提供 app 生命週期回呼：&lt;/p>
&lt;ul>
&lt;li>&lt;code>didChangeAppLifecycleState(AppLifecycleState state)&lt;/code> — app 在 resumed（前景）、inactive（部分可見）、paused（背景）、detached（即將關閉）之間切換。&lt;/li>
&lt;/ul>
&lt;p>SDK 在 init 時註冊 observer，記錄每次狀態轉換為 lifecycle 事件。&lt;/p>
&lt;p>lifecycle 事件在 flush 策略中有特殊意義：&lt;/p>
&lt;p>&lt;strong>paused（進入背景）&lt;/strong>：觸發 flush — 把 buffer 中的事件送出，因為 app 在背景可能被系統殺掉，buffer 中的事件會遺失。iOS 在 app 進入背景後約 5 秒 suspend，flush 必須在這個時間窗口內完成。&lt;/p>
&lt;p>&lt;strong>resumed（回到前景）&lt;/strong>：檢查上次 flush 是否成功。如果 paused 時的 flush 失敗（網路超時），在 resumed 時重試。&lt;/p>
&lt;p>&lt;strong>detached（即將關閉）&lt;/strong>：呼叫 &lt;code>Monitor.close()&lt;/code> 做最後一次 flush 和資源釋放。detached 的時間窗口更短，close flush 可能被截斷。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>Python 平台的適配 → &lt;a href="https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/python-platform/" data-link-title="Python 平台適配" data-link-desc="GIL 與 threading、atexit 可靠性、subprocess 監控 — Python SDK 的平台特殊考量">Python 平台適配&lt;/a>&lt;/li>
&lt;li>跨平台 timestamp 一致性 → &lt;a href="https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/cross-platform-timestamp/" data-link-title="跨平台 timestamp 一致性" data-link-desc="時區、精度、clock drift — 不同平台產生的 timestamp 在 collector 端需要能正確比對和排序">跨平台 timestamp 一致性&lt;/a>&lt;/li>
&lt;li>自動攔截機制 → &lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/auto-intercept/" data-link-title="自動攔截機制" data-link-desc="JS window.onerror / Flutter FlutterError.onError / Python sys.excepthook — 各平台攔截未捕獲例外的機制和限制">模組三 自動攔截&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Flutter 應用程式在 Dart VM 中執行，有自己的執行緒模型（Isolate）、原生平台橋接（Platform channel）和 app 生命週期管理。監控 SDK 在 Flutter 中需要處理的平台特殊問題集中在這三個面向。</p>
<h2 id="isolate-安全">Isolate 安全</h2>
<p>Dart 的 Isolate 是獨立的記憶體空間，Isolate 之間不共享記憶體，只能透過 message passing 溝通。SDK 的記憶體 buffer 存在於 main isolate 中，其他 isolate 產生的事件需要透過 port 傳送到 main isolate 才能進入 buffer。</p>
<p>SDK 端的適配：</p>
<p>提供 <code>Monitor.eventFromIsolate(SendPort port)</code> 方法，在子 isolate 中透過 port 把事件送回 main isolate。或者提供 isolate-aware 的 <code>Monitor.init()</code> 變體，在子 isolate 中初始化一個輕量的 event forwarder。</p>
<p>如果 SDK 使用 compute 或 Isolate.spawn 做背景任務（例如壓縮 buffer），需要透過 port 把結果送回 main isolate — 背景 isolate 無法直接存取 main isolate 的 HTTP client 或 buffer。</p>
<h2 id="platform-channel-攔截">Platform channel 攔截</h2>
<p>Flutter 透過 Platform channel 呼叫原生平台功能（iOS 的 Swift/ObjC、Android 的 Kotlin/Java）。Platform channel 的呼叫可能失敗（原生端未實作、參數格式錯誤、原生端拋出例外），這些錯誤在 Dart 端表現為 <code>PlatformException</code>。</p>
<p>SDK 可以攔截 Platform channel 的呼叫記錄每次呼叫的方法名稱、參數、結果和耗時。攔截方式是替換 <code>ServicesBinding.defaultBinaryMessenger</code> 的處理器，在轉發前後記錄事件。</p>
<p>攔截的價值是：Platform channel 的錯誤通常難以 debug（stack trace 跨越 Dart 和原生兩層），監控記錄提供「呼叫了哪個 channel method、傳了什麼參數、在哪一層失敗」的完整 context。</p>
<p>注意：攔截 Platform channel 會增加每次呼叫的延遲（記錄事件的開銷）。對高頻的 Platform channel 呼叫（例如每幀都呼叫的渲染相關 channel），攔截可能影響效能。SDK 應該提供 channel 過濾機制 — 只攔截特定 channel 或只在 debug mode 攔截。</p>
<h2 id="app-lifecycle-事件">App lifecycle 事件</h2>
<p>Flutter 的 <code>WidgetsBindingObserver</code> 提供 app 生命週期回呼：</p>
<ul>
<li><code>didChangeAppLifecycleState(AppLifecycleState state)</code> — app 在 resumed（前景）、inactive（部分可見）、paused（背景）、detached（即將關閉）之間切換。</li>
</ul>
<p>SDK 在 init 時註冊 observer，記錄每次狀態轉換為 lifecycle 事件。</p>
<p>lifecycle 事件在 flush 策略中有特殊意義：</p>
<p><strong>paused（進入背景）</strong>：觸發 flush — 把 buffer 中的事件送出，因為 app 在背景可能被系統殺掉，buffer 中的事件會遺失。iOS 在 app 進入背景後約 5 秒 suspend，flush 必須在這個時間窗口內完成。</p>
<p><strong>resumed（回到前景）</strong>：檢查上次 flush 是否成功。如果 paused 時的 flush 失敗（網路超時），在 resumed 時重試。</p>
<p><strong>detached（即將關閉）</strong>：呼叫 <code>Monitor.close()</code> 做最後一次 flush 和資源釋放。detached 的時間窗口更短，close flush 可能被截斷。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Python 平台的適配 → <a href="/blog/monitoring/05-platform-adaptation/python-platform/" data-link-title="Python 平台適配" data-link-desc="GIL 與 threading、atexit 可靠性、subprocess 監控 — Python SDK 的平台特殊考量">Python 平台適配</a></li>
<li>跨平台 timestamp 一致性 → <a href="/blog/monitoring/05-platform-adaptation/cross-platform-timestamp/" data-link-title="跨平台 timestamp 一致性" data-link-desc="時區、精度、clock drift — 不同平台產生的 timestamp 在 collector 端需要能正確比對和排序">跨平台 timestamp 一致性</a></li>
<li>自動攔截機制 → <a href="/blog/monitoring/03-sdk-design/auto-intercept/" data-link-title="自動攔截機制" data-link-desc="JS window.onerror / Flutter FlutterError.onError / Python sys.excepthook — 各平台攔截未捕獲例外的機制和限制">模組三 自動攔截</a></li>
</ul>
]]></content:encoded></item><item><title>Funnel Analysis</title><link>https://tarrragon.github.io/blog/monitoring/08-business-analytics/funnel-analysis/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/08-business-analytics/funnel-analysis/</guid><description>&lt;p>Funnel analysis 計算使用者在一連串步驟中每一步的轉換率，回答「使用者在哪一步離開」。流失最嚴重的步驟是優化投資報酬率最高的位置 — 修一個步驟的流失比優化所有步驟的體驗更有效。&lt;/p>
&lt;h2 id="基本計算">基本計算&lt;/h2>
&lt;p>Funnel 的每一步有兩個數字：進入人數和完成人數。轉換率 = 完成人數 / 進入人數。&lt;/p>
&lt;p>以四步註冊 funnel 為例：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>步驟&lt;/th>
 &lt;th>進入人數&lt;/th>
 &lt;th>完成人數&lt;/th>
 &lt;th>轉換率&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>看到註冊頁&lt;/td>
 &lt;td>1000&lt;/td>
 &lt;td>1000&lt;/td>
 &lt;td>100%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>填寫表單&lt;/td>
 &lt;td>1000&lt;/td>
 &lt;td>620&lt;/td>
 &lt;td>62%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>送出表單&lt;/td>
 &lt;td>620&lt;/td>
 &lt;td>580&lt;/td>
 &lt;td>93.5%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>完成 email 驗證&lt;/td>
 &lt;td>580&lt;/td>
 &lt;td>310&lt;/td>
 &lt;td>53.4%&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>整體轉換率 = 310 / 1000 = 31%。但更有價值的資訊在每步的轉換率：步驟 2（填寫表單）流失 38%，步驟 4（email 驗證）流失 46.6%。這兩步是優化的優先目標。&lt;/p>
&lt;h2 id="流失原因的區分">流失原因的區分&lt;/h2>
&lt;p>Funnel analysis 指出「哪一步流失」，但不直接回答「為什麼流失」。流失原因需要結合其他資料推斷。&lt;/p>
&lt;h3 id="設計問題導致的流失">設計問題導致的流失&lt;/h3>
&lt;p>使用者看到表單但沒填寫（步驟 2 流失 38%）。可能原因：表單欄位太多、要求的資訊太敏感（信用卡號在註冊階段）、表單 UI 在特定裝置上有問題。&lt;/p>
&lt;p>判斷方式：按平台、裝置、螢幕尺寸細分轉換率。如果 iOS 轉換率 70% 但 Android 只有 45%，可能是 Android 的表單 UI 有問題。&lt;/p>
&lt;h3 id="技術問題導致的流失">技術問題導致的流失&lt;/h3>
&lt;p>使用者送出表單但 email 驗證沒完成（步驟 4 流失 46.6%）。可能原因：驗證信被歸到垃圾郵件、驗證連結過期太快、驗證頁面載入失敗。&lt;/p>
&lt;p>判斷方式：結合 error 事件。如果步驟 4 有大量 &lt;code>signup.email.verify.failed&lt;/code> error，是技術問題；如果沒有 error 但流失高，使用者可能沒收到信或沒看到信。&lt;/p>
&lt;h3 id="意圖問題導致的流失">意圖問題導致的流失&lt;/h3>
&lt;p>使用者到了註冊頁但根本沒打算註冊 — 只是瀏覽。這類流失不是問題，是正常的使用者行為。&lt;/p>
&lt;p>判斷方式：看使用者在流失步驟停留的時間。停留 &amp;lt; 3 秒就離開，可能是誤點或沒有註冊意圖；停留 &amp;gt; 30 秒但沒完成，可能是遇到障礙。&lt;/p>
&lt;h2 id="funnel-的時間窗口">Funnel 的時間窗口&lt;/h2>
&lt;p>同一個使用者在步驟 A 和步驟 B 之間隔了多久，仍算在同一個 funnel 內？時間窗口的設定影響 funnel 的計算結果。&lt;/p>
&lt;p>&lt;strong>窗口太短&lt;/strong>：使用者中途離開稍後回來完成，被計為流失。Funnel 的流失率被高估。&lt;/p>
&lt;p>&lt;strong>窗口太長&lt;/strong>：使用者今天瀏覽、一個月後被廣告重新帶回來完成，兩次獨立的意圖被合併成一個 funnel。轉換率被高估。&lt;/p>
&lt;p>合理的窗口依業務場景而定：電商結帳 funnel 用 30 分鐘到 1 小時，SaaS onboarding 用 7 天，B2B 銷售漏斗用 30-90 天。&lt;/p>
&lt;h2 id="畫面狀態矩陣和-funnel-的關係">畫面狀態矩陣和 funnel 的關係&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣&lt;/a>（&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>）描述每個畫面的狀態和轉換。Funnel 描述使用者跨畫面的操作路徑。兩者的對應是：funnel 的每一步通常對應一個畫面狀態的進入事件。&lt;/p>
&lt;p>狀態矩陣中的退出路徑（back 按鈕、取消操作）就是 funnel 的流失點。狀態矩陣的退出路徑為空 = UX 死胡同，funnel 分析中表現為「使用者進入後既沒完成也沒退出 — session 中斷」。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>不同使用者群體的行為差異 → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/cohort-analysis/" data-link-title="Cohort Analysis" data-link-desc="按共同特徵分群、比較不同群體的留存率和行為差異 — 從「平均值」到「誰在用、誰離開了」">Cohort analysis&lt;/a>&lt;/li>
&lt;li>行為事件的設計 → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/behavior-event-design/" data-link-title="行為事件設計" data-link-desc="事件命名規範、屬性設計、funnel 定義 — 行為分析的品質取決於事件設計的品質">行為事件設計&lt;/a>&lt;/li>
&lt;li>自架方案做 funnel → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/self-hosted-funnel/" data-link-title="從 collector 資料做基礎 funnel 分析" data-link-desc="SQLite 層能做什麼程度的 funnel、PostgreSQL 層提供什麼進階能力、JSONL 匯出後的臨時分析">從 collector 資料做基礎 funnel 分析&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Funnel analysis 計算使用者在一連串步驟中每一步的轉換率，回答「使用者在哪一步離開」。流失最嚴重的步驟是優化投資報酬率最高的位置 — 修一個步驟的流失比優化所有步驟的體驗更有效。</p>
<h2 id="基本計算">基本計算</h2>
<p>Funnel 的每一步有兩個數字：進入人數和完成人數。轉換率 = 完成人數 / 進入人數。</p>
<p>以四步註冊 funnel 為例：</p>
<table>
  <thead>
      <tr>
          <th>步驟</th>
          <th>進入人數</th>
          <th>完成人數</th>
          <th>轉換率</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>看到註冊頁</td>
          <td>1000</td>
          <td>1000</td>
          <td>100%</td>
      </tr>
      <tr>
          <td>填寫表單</td>
          <td>1000</td>
          <td>620</td>
          <td>62%</td>
      </tr>
      <tr>
          <td>送出表單</td>
          <td>620</td>
          <td>580</td>
          <td>93.5%</td>
      </tr>
      <tr>
          <td>完成 email 驗證</td>
          <td>580</td>
          <td>310</td>
          <td>53.4%</td>
      </tr>
  </tbody>
</table>
<p>整體轉換率 = 310 / 1000 = 31%。但更有價值的資訊在每步的轉換率：步驟 2（填寫表單）流失 38%，步驟 4（email 驗證）流失 46.6%。這兩步是優化的優先目標。</p>
<h2 id="流失原因的區分">流失原因的區分</h2>
<p>Funnel analysis 指出「哪一步流失」，但不直接回答「為什麼流失」。流失原因需要結合其他資料推斷。</p>
<h3 id="設計問題導致的流失">設計問題導致的流失</h3>
<p>使用者看到表單但沒填寫（步驟 2 流失 38%）。可能原因：表單欄位太多、要求的資訊太敏感（信用卡號在註冊階段）、表單 UI 在特定裝置上有問題。</p>
<p>判斷方式：按平台、裝置、螢幕尺寸細分轉換率。如果 iOS 轉換率 70% 但 Android 只有 45%，可能是 Android 的表單 UI 有問題。</p>
<h3 id="技術問題導致的流失">技術問題導致的流失</h3>
<p>使用者送出表單但 email 驗證沒完成（步驟 4 流失 46.6%）。可能原因：驗證信被歸到垃圾郵件、驗證連結過期太快、驗證頁面載入失敗。</p>
<p>判斷方式：結合 error 事件。如果步驟 4 有大量 <code>signup.email.verify.failed</code> error，是技術問題；如果沒有 error 但流失高，使用者可能沒收到信或沒看到信。</p>
<h3 id="意圖問題導致的流失">意圖問題導致的流失</h3>
<p>使用者到了註冊頁但根本沒打算註冊 — 只是瀏覽。這類流失不是問題，是正常的使用者行為。</p>
<p>判斷方式：看使用者在流失步驟停留的時間。停留 &lt; 3 秒就離開，可能是誤點或沒有註冊意圖；停留 &gt; 30 秒但沒完成，可能是遇到障礙。</p>
<h2 id="funnel-的時間窗口">Funnel 的時間窗口</h2>
<p>同一個使用者在步驟 A 和步驟 B 之間隔了多久，仍算在同一個 funnel 內？時間窗口的設定影響 funnel 的計算結果。</p>
<p><strong>窗口太短</strong>：使用者中途離開稍後回來完成，被計為流失。Funnel 的流失率被高估。</p>
<p><strong>窗口太長</strong>：使用者今天瀏覽、一個月後被廣告重新帶回來完成，兩次獨立的意圖被合併成一個 funnel。轉換率被高估。</p>
<p>合理的窗口依業務場景而定：電商結帳 funnel 用 30 分鐘到 1 小時，SaaS onboarding 用 7 天，B2B 銷售漏斗用 30-90 天。</p>
<h2 id="畫面狀態矩陣和-funnel-的關係">畫面狀態矩陣和 funnel 的關係</h2>
<p><a href="/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣</a>（<a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一</a>）描述每個畫面的狀態和轉換。Funnel 描述使用者跨畫面的操作路徑。兩者的對應是：funnel 的每一步通常對應一個畫面狀態的進入事件。</p>
<p>狀態矩陣中的退出路徑（back 按鈕、取消操作）就是 funnel 的流失點。狀態矩陣的退出路徑為空 = UX 死胡同，funnel 分析中表現為「使用者進入後既沒完成也沒退出 — session 中斷」。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>不同使用者群體的行為差異 → <a href="/blog/monitoring/08-business-analytics/cohort-analysis/" data-link-title="Cohort Analysis" data-link-desc="按共同特徵分群、比較不同群體的留存率和行為差異 — 從「平均值」到「誰在用、誰離開了」">Cohort analysis</a></li>
<li>行為事件的設計 → <a href="/blog/monitoring/08-business-analytics/behavior-event-design/" data-link-title="行為事件設計" data-link-desc="事件命名規範、屬性設計、funnel 定義 — 行為分析的品質取決於事件設計的品質">行為事件設計</a></li>
<li>自架方案做 funnel → <a href="/blog/monitoring/08-business-analytics/self-hosted-funnel/" data-link-title="從 collector 資料做基礎 funnel 分析" data-link-desc="SQLite 層能做什麼程度的 funnel、PostgreSQL 層提供什麼進階能力、JSONL 匯出後的臨時分析">從 collector 資料做基礎 funnel 分析</a></li>
</ul>
]]></content:encoded></item><item><title>JSONL 匯出與備份格式</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/jsonl-storage/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/jsonl-storage/</guid><description>&lt;p>Collector 的 day-one 主要儲存是 SQLite（見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進&lt;/a>）。JSONL（JSON Lines）保留作為匯出和備份格式 — 人類可讀、grep 友好、SQLite 資料庫損壞時可以從 JSONL 重建。Collector 提供 &lt;code>monitor export --format=jsonl&lt;/code> 指令匯出事件，也可以設定同步寫入 JSONL 作為即時備份。&lt;/p>
&lt;p>JSONL 的格式是每行一個 JSON 物件。作為匯出格式，核心優勢是工具鏈成熟 — &lt;code>grep&lt;/code> 過濾、&lt;code>jq&lt;/code> 結構化查詢、&lt;code>tail -f&lt;/code> 即時監控，不需要 database client。&lt;/p>
&lt;h2 id="一天一檔">一天一檔&lt;/h2>
&lt;p>事件按日期分檔：&lt;code>events-2026-06-19.jsonl&lt;/code>、&lt;code>events-2026-06-20.jsonl&lt;/code>。每天零點（或 UTC 日期變更時）切換到新檔案。&lt;/p>
&lt;p>一天一檔的好處：&lt;/p>
&lt;p>&lt;strong>時間範圍查詢直接對應到檔案&lt;/strong>。查「昨天的 error」只需要讀一個檔案，不需要掃描整個資料集。&lt;/p>
&lt;p>&lt;strong>保留策略按檔案操作&lt;/strong>。保留 30 天的資料 = 刪除 30 天前的檔案。不需要 database 的 TTL 機制或 partition pruning。&lt;/p>
&lt;p>&lt;strong>備份和搬移按檔案操作&lt;/strong>。rsync 一個目錄就完成備份；搬移特定日期的資料 = 搬移對應檔案。&lt;/p>
&lt;p>一天一檔的風險是單日資料量過大時，單一檔案的 grep 查詢會變慢。自用工具場景下，單日事件量通常在數千到數萬筆，檔案大小在 MB 級，grep 查詢在秒級完成。當單日事件量超過百萬筆時，需要考慮演進到更適合的儲存方案（見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進&lt;/a>）。&lt;/p>
&lt;h2 id="append-only-寫入">Append-only 寫入&lt;/h2>
&lt;p>JSONL 的寫入模式是 append-only — 新事件追加到檔案尾端，已寫入的事件不修改。&lt;/p>
&lt;p>Append-only 的操作特性：&lt;/p>
&lt;p>&lt;strong>寫入不需要鎖&lt;/strong>。&lt;code>os.OpenFile&lt;/code> 用 &lt;code>O_APPEND&lt;/code> flag 開啟，OS 保證每次 write 是 atomic 的（在 write size 不超過 &lt;code>PIPE_BUF&lt;/code> 的前提下，Linux 上是 4096 bytes）。單一事件的 JSON 通常在這個限制內。&lt;/p>
&lt;p>&lt;strong>不會損壞既有資料&lt;/strong>。寫入失敗（磁碟滿、程序崩潰）最多造成最後一行不完整，不影響前面的行。恢復時刪除最後一行的不完整片段即可。&lt;/p>
&lt;p>&lt;strong>支援 tail -f 即時監控&lt;/strong>。&lt;code>tail -f events-2026-06-19.jsonl | jq .&lt;/code> 即時顯示新寫入的事件，不需要額外的 streaming 機制。&lt;/p>
&lt;h2 id="gzip-壓縮">Gzip 壓縮&lt;/h2>
&lt;p>歷史檔案（非當天的）用 gzip 壓縮。JSON 文字的壓縮率通常在 80-90%（10MB 壓縮到 1-2MB）。&lt;/p>
&lt;p>壓縮策略：&lt;/p>
&lt;p>&lt;strong>當天的檔案不壓縮&lt;/strong>。保持 append-only 和 tail -f 的能力。&lt;/p>
&lt;p>&lt;strong>日期切換時壓縮前一天的檔案&lt;/strong>。用 cron job 或 collector 啟動時檢查，把 &lt;code>events-2026-06-18.jsonl&lt;/code> 壓縮為 &lt;code>events-2026-06-18.jsonl.gz&lt;/code>。&lt;/p>
&lt;p>&lt;strong>查詢壓縮檔用 zgrep / zcat&lt;/strong>。&lt;code>zgrep &amp;quot;error&amp;quot; events-2026-06-18.jsonl.gz&lt;/code> 不需要先解壓。&lt;/p>
&lt;h2 id="jsonl-備份的保留">JSONL 備份的保留&lt;/h2>
&lt;p>JSONL 備份檔的保留策略和 SQLite 主要儲存的分層保留獨立 — JSONL 是最後的重建來源，保留期限可以比 SQLite 中的原始事件更長。&lt;/p>
&lt;p>典型配置：JSONL 備份保留 30 天（即使 SQLite 中的原始事件只保留 7 天），提供 SQLite 損壞時的 30 天重建窗口。超過 30 天的 JSONL 壓縮檔用 cron job 清理：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">find /var/lib/collector/events/ -name &lt;span class="s2">&amp;#34;events-*.jsonl.gz&amp;#34;&lt;/span> -mtime +30 -delete&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>主要儲存的查詢驅動分層保留策略見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Collector 的 day-one 主要儲存是 SQLite（見 <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a>）。JSONL（JSON Lines）保留作為匯出和備份格式 — 人類可讀、grep 友好、SQLite 資料庫損壞時可以從 JSONL 重建。Collector 提供 <code>monitor export --format=jsonl</code> 指令匯出事件，也可以設定同步寫入 JSONL 作為即時備份。</p>
<p>JSONL 的格式是每行一個 JSON 物件。作為匯出格式，核心優勢是工具鏈成熟 — <code>grep</code> 過濾、<code>jq</code> 結構化查詢、<code>tail -f</code> 即時監控，不需要 database client。</p>
<h2 id="一天一檔">一天一檔</h2>
<p>事件按日期分檔：<code>events-2026-06-19.jsonl</code>、<code>events-2026-06-20.jsonl</code>。每天零點（或 UTC 日期變更時）切換到新檔案。</p>
<p>一天一檔的好處：</p>
<p><strong>時間範圍查詢直接對應到檔案</strong>。查「昨天的 error」只需要讀一個檔案，不需要掃描整個資料集。</p>
<p><strong>保留策略按檔案操作</strong>。保留 30 天的資料 = 刪除 30 天前的檔案。不需要 database 的 TTL 機制或 partition pruning。</p>
<p><strong>備份和搬移按檔案操作</strong>。rsync 一個目錄就完成備份；搬移特定日期的資料 = 搬移對應檔案。</p>
<p>一天一檔的風險是單日資料量過大時，單一檔案的 grep 查詢會變慢。自用工具場景下，單日事件量通常在數千到數萬筆，檔案大小在 MB 級，grep 查詢在秒級完成。當單日事件量超過百萬筆時，需要考慮演進到更適合的儲存方案（見 <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a>）。</p>
<h2 id="append-only-寫入">Append-only 寫入</h2>
<p>JSONL 的寫入模式是 append-only — 新事件追加到檔案尾端，已寫入的事件不修改。</p>
<p>Append-only 的操作特性：</p>
<p><strong>寫入不需要鎖</strong>。<code>os.OpenFile</code> 用 <code>O_APPEND</code> flag 開啟，OS 保證每次 write 是 atomic 的（在 write size 不超過 <code>PIPE_BUF</code> 的前提下，Linux 上是 4096 bytes）。單一事件的 JSON 通常在這個限制內。</p>
<p><strong>不會損壞既有資料</strong>。寫入失敗（磁碟滿、程序崩潰）最多造成最後一行不完整，不影響前面的行。恢復時刪除最後一行的不完整片段即可。</p>
<p><strong>支援 tail -f 即時監控</strong>。<code>tail -f events-2026-06-19.jsonl | jq .</code> 即時顯示新寫入的事件，不需要額外的 streaming 機制。</p>
<h2 id="gzip-壓縮">Gzip 壓縮</h2>
<p>歷史檔案（非當天的）用 gzip 壓縮。JSON 文字的壓縮率通常在 80-90%（10MB 壓縮到 1-2MB）。</p>
<p>壓縮策略：</p>
<p><strong>當天的檔案不壓縮</strong>。保持 append-only 和 tail -f 的能力。</p>
<p><strong>日期切換時壓縮前一天的檔案</strong>。用 cron job 或 collector 啟動時檢查，把 <code>events-2026-06-18.jsonl</code> 壓縮為 <code>events-2026-06-18.jsonl.gz</code>。</p>
<p><strong>查詢壓縮檔用 zgrep / zcat</strong>。<code>zgrep &quot;error&quot; events-2026-06-18.jsonl.gz</code> 不需要先解壓。</p>
<h2 id="jsonl-備份的保留">JSONL 備份的保留</h2>
<p>JSONL 備份檔的保留策略和 SQLite 主要儲存的分層保留獨立 — JSONL 是最後的重建來源，保留期限可以比 SQLite 中的原始事件更長。</p>
<p>典型配置：JSONL 備份保留 30 天（即使 SQLite 中的原始事件只保留 7 天），提供 SQLite 損壞時的 30 天重建窗口。超過 30 天的 JSONL 壓縮檔用 cron job 清理：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">find /var/lib/collector/events/ -name <span class="s2">&#34;events-*.jsonl.gz&#34;</span> -mtime +30 -delete</span></span></code></pre></div><p>主要儲存的查詢驅動分層保留策略見 <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a>。</p>
<h2 id="匯出的實作注意">匯出的實作注意</h2>
<p>匯出使用 streaming — 從 storage 逐筆讀取、逐行寫出，記憶體使用和事件總量無關。300 萬筆事件（約 900MB JSONL）不需要整批載入記憶體。</p>
<p>匯出的 JSONL 檔案包含事件明文（已 redaction 的欄位除外）。匯出後的檔案不受 collector 的存取控制保護，注意存放位置和存取權限。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<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>查詢設計 → <a href="/blog/monitoring/04-collector/query-api/" data-link-title="查詢 API 設計" data-link-desc="CLI grep 友好的 JSONL 結構 &#43; HTTP 查詢 endpoint — 兩種查詢介面各自的適用場景和設計要點">查詢 API 設計</a></li>
<li>儲存撐不住時的演進 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a></li>
</ul>
]]></content:encoded></item><item><title>Redaction</title><link>https://tarrragon.github.io/blog/monitoring/knowledge-cards/redaction/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/knowledge-cards/redaction/</guid><description>&lt;p>Redaction 的核心概念是「在事件資料離開 client 之前，把敏感欄位的值替換成遮罩或移除」。密碼、API key、個人識別資訊在送到 collector 之前就被處理，確保敏感資料不進入傳輸和儲存層。可先對照 &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>Redaction 位在 SDK 端的事件產生和 collector 端的事件接收之間。它是監控資料安全的第一道防線 — 在資料離開使用者裝置之前處理，比 collector 端的 access control 更早介入。Redaction 和 transport 加密（HTTPS）互補：redaction 保護欄位內容，transport 加密保護傳輸過程。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>系統需要 redaction 的訊號是監控事件的 data 欄位可能包含使用者輸入。CLI 輸入可能含密碼（&lt;code>mysql -p'secret'&lt;/code>）、API key（&lt;code>Authorization: Bearer sk-...&lt;/code>）、連線字串（含帳密的 URL）。IME 個人化學習也是洩漏面 — 輸入框的內容被 IME 學習後跨 app 可見。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Redaction 要定義預設規則（哪些欄位名稱自動 redact）、自訂 pattern（正則表達式比對敏感值）、執行時機（event 進入 buffer 前還是 flush 時）、以及 redaction 失敗的處理（丟棄整筆事件 vs 只移除敏感欄位）。&lt;/p></description><content:encoded><![CDATA[<p>Redaction 的核心概念是「在事件資料離開 client 之前，把敏感欄位的值替換成遮罩或移除」。密碼、API key、個人識別資訊在送到 collector 之前就被處理，確保敏感資料不進入傳輸和儲存層。可先對照 <a href="/blog/monitoring/knowledge-cards/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="說明追蹤使用者在多步驟流程中每一步的轉換率和流失率的分析方法">funnel analysis</a>（去識別化是行為分析的入場條件）。</p>
<h2 id="概念位置">概念位置</h2>
<p>Redaction 位在 SDK 端的事件產生和 collector 端的事件接收之間。它是監控資料安全的第一道防線 — 在資料離開使用者裝置之前處理，比 collector 端的 access control 更早介入。Redaction 和 transport 加密（HTTPS）互補：redaction 保護欄位內容，transport 加密保護傳輸過程。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>系統需要 redaction 的訊號是監控事件的 data 欄位可能包含使用者輸入。CLI 輸入可能含密碼（<code>mysql -p'secret'</code>）、API key（<code>Authorization: Bearer sk-...</code>）、連線字串（含帳密的 URL）。IME 個人化學習也是洩漏面 — 輸入框的內容被 IME 學習後跨 app 可見。</p>
<h2 id="設計責任">設計責任</h2>
<p>Redaction 要定義預設規則（哪些欄位名稱自動 redact）、自訂 pattern（正則表達式比對敏感值）、執行時機（event 進入 buffer 前還是 flush 時）、以及 redaction 失敗的處理（丟棄整筆事件 vs 只移除敏感欄位）。</p>
]]></content:encoded></item><item><title>Sentry 深入</title><link>https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/sentry-deep-dive/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/sentry-deep-dive/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>跟 Backend 04 的分工&lt;/strong>：本文從 client-side 使用角度說明 Sentry 的 error tracking、performance monitoring 與 session replay — SDK 怎麼埋、error 怎麼分群、release 怎麼追蹤。Server-side 平台治理（告警路由整合、SLI 指標設計、self-hosted vs SaaS 成本治理、跟 OTel 的整合）見 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/vendors/sentry/" data-link-title="Sentry" data-link-desc="Error tracking 主流、APM / Profiling / Session Replay 擴展">Backend 04 Sentry vendor page&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;p>Sentry 的核心是 error tracking — 自動捕獲未處理的例外、提供 stack trace、自動分群（grouping）相同 root cause 的 error。在 error tracking 的基礎上，Sentry 擴展了 performance monitoring（transaction / span）和 session replay（重播使用者操作）。&lt;/p>
&lt;h2 id="error-tracking">Error tracking&lt;/h2>
&lt;p>Sentry 的 error tracking 架構有三個層次：SDK 端的自動捕獲、server 端的 issue grouping 和 UI 端的 issue management。&lt;/p>
&lt;h3 id="自動捕獲">自動捕獲&lt;/h3>
&lt;p>Sentry SDK 在各平台註冊全域錯誤處理器（和&lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/auto-intercept/" data-link-title="自動攔截機制" data-link-desc="JS window.onerror / Flutter FlutterError.onError / Python sys.excepthook — 各平台攔截未捕獲例外的機制和限制">模組三 自動攔截&lt;/a>的機制相同）。捕獲到例外後，SDK 收集 stack trace、breadcrumbs（最近的使用者操作）、device context（OS / browser / device model）和自訂 tags，打包成 event 送到 Sentry server。&lt;/p>
&lt;h3 id="issue-grouping">Issue grouping&lt;/h3>
&lt;p>Sentry server 收到 error event 後，用 fingerprinting 演算法判斷這個 error 是否和已有的 issue 相同。預設的 fingerprinting 基於 stack trace 的 frame — 如果兩個 error 的 stack trace 指向同一個位置，歸入同一個 issue。&lt;/p>
&lt;p>自訂 fingerprint 讓開發者控制 grouping 邏輯。例如：不同使用者觸發的同一個 API error 可能有不同的 stack trace（因為 call site 不同），但 root cause 相同 — 自訂 fingerprint 把它們歸入同一個 issue。&lt;/p>
&lt;h3 id="issue-management">Issue management&lt;/h3>
&lt;p>每個 issue 有狀態（unresolved / resolved / ignored）、指派（誰負責修復）、趨勢（這個 issue 的發生頻率是上升還是下降）。Sentry 的 UI 提供 issue 列表、趨勢圖、影響範圍（影響多少使用者）。&lt;/p>
&lt;h2 id="performance-monitoring">Performance monitoring&lt;/h2>
&lt;p>Sentry 的 performance monitoring 用 transaction 和 span 模型（和 OpenTelemetry 的 trace / span 概念相同）。&lt;/p>
&lt;p>Transaction 代表一個完整的操作（頁面載入、API 請求處理）。Span 是 transaction 內的子操作（database query、外部 API 呼叫）。Transaction 和 span 的 duration 構成操作的時間分佈。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>跟 Backend 04 的分工</strong>：本文從 client-side 使用角度說明 Sentry 的 error tracking、performance monitoring 與 session replay — SDK 怎麼埋、error 怎麼分群、release 怎麼追蹤。Server-side 平台治理（告警路由整合、SLI 指標設計、self-hosted vs SaaS 成本治理、跟 OTel 的整合）見 <a href="/blog/backend/04-observability/vendors/sentry/" data-link-title="Sentry" data-link-desc="Error tracking 主流、APM / Profiling / Session Replay 擴展">Backend 04 Sentry vendor page</a>。</p></blockquote>
<p>Sentry 的核心是 error tracking — 自動捕獲未處理的例外、提供 stack trace、自動分群（grouping）相同 root cause 的 error。在 error tracking 的基礎上，Sentry 擴展了 performance monitoring（transaction / span）和 session replay（重播使用者操作）。</p>
<h2 id="error-tracking">Error tracking</h2>
<p>Sentry 的 error tracking 架構有三個層次：SDK 端的自動捕獲、server 端的 issue grouping 和 UI 端的 issue management。</p>
<h3 id="自動捕獲">自動捕獲</h3>
<p>Sentry SDK 在各平台註冊全域錯誤處理器（和<a href="/blog/monitoring/03-sdk-design/auto-intercept/" data-link-title="自動攔截機制" data-link-desc="JS window.onerror / Flutter FlutterError.onError / Python sys.excepthook — 各平台攔截未捕獲例外的機制和限制">模組三 自動攔截</a>的機制相同）。捕獲到例外後，SDK 收集 stack trace、breadcrumbs（最近的使用者操作）、device context（OS / browser / device model）和自訂 tags，打包成 event 送到 Sentry server。</p>
<h3 id="issue-grouping">Issue grouping</h3>
<p>Sentry server 收到 error event 後，用 fingerprinting 演算法判斷這個 error 是否和已有的 issue 相同。預設的 fingerprinting 基於 stack trace 的 frame — 如果兩個 error 的 stack trace 指向同一個位置，歸入同一個 issue。</p>
<p>自訂 fingerprint 讓開發者控制 grouping 邏輯。例如：不同使用者觸發的同一個 API error 可能有不同的 stack trace（因為 call site 不同），但 root cause 相同 — 自訂 fingerprint 把它們歸入同一個 issue。</p>
<h3 id="issue-management">Issue management</h3>
<p>每個 issue 有狀態（unresolved / resolved / ignored）、指派（誰負責修復）、趨勢（這個 issue 的發生頻率是上升還是下降）。Sentry 的 UI 提供 issue 列表、趨勢圖、影響範圍（影響多少使用者）。</p>
<h2 id="performance-monitoring">Performance monitoring</h2>
<p>Sentry 的 performance monitoring 用 transaction 和 span 模型（和 OpenTelemetry 的 trace / span 概念相同）。</p>
<p>Transaction 代表一個完整的操作（頁面載入、API 請求處理）。Span 是 transaction 內的子操作（database query、外部 API 呼叫）。Transaction 和 span 的 duration 構成操作的時間分佈。</p>
<p>Performance monitoring 的價值是發現「慢」的問題 — P95 回應時間超過閾值、特定 span 佔了 transaction 80% 的時間。和 error tracking 互補：error 告訴你「什麼壞了」，performance 告訴你「什麼慢了」。</p>
<h2 id="session-replay">Session replay</h2>
<p>Session replay 錄製使用者的操作過程 — DOM 變化、滑鼠移動、點擊事件 — 在 Sentry UI 中重播。開發者可以看到「使用者在觸發 error 之前做了什麼操作」。</p>
<p>Session replay 的實作是 DOM snapshot + mutation recording。記錄的是 DOM 結構的變化（非螢幕錄影），在重播時重建 DOM。資料量比錄影小很多，但仍然是所有 Sentry 功能中資料量最大的。</p>
<p>隱私考量：session replay 會看到使用者輸入的內容（除非做 masking）。Sentry 提供 privacy configuration 控制哪些元素被 mask（輸入框、敏感資料區域）。</p>
<h2 id="自架方案和-sentry-的差距">自架方案和 Sentry 的差距</h2>
<table>
  <thead>
      <tr>
          <th>功能</th>
          <th>自架方案</th>
          <th>Sentry</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Error 捕獲</td>
          <td>SDK 自動攔截</td>
          <td>SDK 自動攔截（相同）</td>
      </tr>
      <tr>
          <td>Issue grouping</td>
          <td>手動 grep 分群</td>
          <td>自動 fingerprinting + 自訂規則</td>
      </tr>
      <tr>
          <td>趨勢分析</td>
          <td>手動計數</td>
          <td>自動趨勢圖 + 告警</td>
      </tr>
      <tr>
          <td>Performance</td>
          <td>metric 事件 + 手動分析</td>
          <td>Transaction / span + 自動 P95</td>
      </tr>
      <tr>
          <td>Session replay</td>
          <td>無</td>
          <td>DOM recording + 重播 UI</td>
      </tr>
  </tbody>
</table>
<p>Sentry 的核心價值在 issue grouping 和趨勢分析 — 把大量 error event 歸類成可管理的 issue 列表，自動追蹤每個 issue 的趨勢。自架方案用 grep 做不到自動 grouping。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Firebase 的整合方案 → <a href="/blog/monitoring/06-commercial-comparison/firebase-suite/" data-link-title="Firebase 套件" data-link-desc="Crashlytics &#43; Analytics &#43; Remote Config 的整合 — Firebase 把 error tracking 和行為分析拆成獨立產品的設計取捨">Firebase 套件</a></li>
<li>Datadog 的全棧 APM → <a href="/blog/monitoring/06-commercial-comparison/datadog-rum/" data-link-title="Datadog RUM" data-link-desc="全棧 APM 的 client-side 觀點 — client action 到 server trace 的完整鏈路追蹤">Datadog RUM</a></li>
<li>自架 vs 商業的判斷 → <a href="/blog/monitoring/06-commercial-comparison/self-hosted-vs-commercial/" data-link-title="自架 vs 商業的判斷決策表" data-link-desc="使用者數、網路範圍、功能需求、合規要求四個維度判斷該自架還是用商業方案">自架 vs 商業的判斷決策表</a></li>
<li>自架方案的 error fingerprint 實作 → <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></li>
</ul>
]]></content:encoded></item><item><title>Transport 安全</title><link>https://tarrragon.github.io/blog/monitoring/07-security-privacy/transport-security/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/07-security-privacy/transport-security/</guid><description>&lt;p>Transport 安全保護監控資料在從 SDK 傳送到 collector 的過程中不被竊聽或篡改。即使 SDK 端做了 redaction，傳輸中的資料仍然包含使用者行為、系統狀態、error 訊息等有價值的資訊 — 這些資訊在未加密的傳輸中可以被同網段的任何人攔截。&lt;/p>
&lt;h2 id="同區網也要加密的理由">同區網也要加密的理由&lt;/h2>
&lt;p>自用工具的 SDK 和 collector 通常在同一台機器或同一個區域網路（LAN / Tailscale tailnet）。常見的假設是「同區網不需要加密，因為只有我自己在用」。&lt;/p>
&lt;p>這個假設在以下情境不成立：&lt;/p>
&lt;p>&lt;strong>共用網路&lt;/strong>：咖啡廳、共享辦公室、飯店 WiFi — 同一個 AP 下的其他裝置可以用 ARP spoofing 或 WiFi sniffing 攔截未加密的 HTTP 流量。&lt;/p>
&lt;p>&lt;strong>未來的網路拓撲變更&lt;/strong>：目前在同一台機器上的 SDK 和 collector，可能之後拆到不同的機器或不同的網路段。如果一開始就用 HTTPS，拓撲變更不需要額外的安全調整。&lt;/p>
&lt;p>&lt;strong>養成正確習慣&lt;/strong>：在自用工具上用 HTTP 是因為「反正只有我」，但相同的開發者在商業專案中可能延續這個習慣。從自用工具開始就用 HTTPS，讓加密傳輸成為預設行為。&lt;/p>
&lt;h2 id="https-設定">HTTPS 設定&lt;/h2>
&lt;h3 id="自簽憑證">自簽憑證&lt;/h3>
&lt;p>自用工具和內部服務用自簽憑證（self-signed certificate）就足夠。不需要購買 CA 憑證 — 自簽憑證提供加密（防竊聽）和完整性（防篡改），只是不提供身份驗證（client 無法確認 server 是不是「官方的」）。在自用場景中 server 就是自己架的，身份驗證不是問題。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days &lt;span class="m">365&lt;/span> -nodes&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Go collector 使用自簽憑證：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ListenAndServeTLS&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;:8443&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;cert.pem&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;key.pem&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">handler&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>SDK 端需要信任自簽憑證。開發期可以在 HTTP client 設定 &lt;code>badCertificateCallback&lt;/code> 接受自簽憑證；production 應該把自簽憑證加入系統的信任清單。&lt;/p>
&lt;h3 id="lets-encrypt">Let&amp;rsquo;s Encrypt&lt;/h3>
&lt;p>如果 collector 有公開的 domain name，用 Let&amp;rsquo;s Encrypt 取得免費的 CA 憑證。自動續期、不需要手動管理。適合部署在 VPS 或雲端的 collector。&lt;/p>
&lt;h2 id="basic-auth">Basic Auth&lt;/h2>
&lt;p>HTTPS 保護傳輸層（防竊聽），basic auth 保護 endpoint 層（防未授權存取）。兩者互補，缺一不可 — basic auth 在 HTTP 上傳送的是 base64 編碼的帳密，沒有 HTTPS 的加密保護等於明文傳送。&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">Authorization: Basic base64(username:password)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>SDK 在每個 HTTP POST request 的 header 中帶上 basic auth。Collector 端驗證帳密，不匹配則回傳 401。&lt;/p>
&lt;p>Basic auth 的帳密管理：&lt;/p>
&lt;ul>
&lt;li>帳密存在 SDK 的設定檔或環境變數中，不硬編碼在程式碼裡&lt;/li>
&lt;li>Collector 端的帳密用 bcrypt hash 儲存，不存明文&lt;/li>
&lt;li>定期輪替帳密（自用工具半年到一年一次即可）&lt;/li>
&lt;/ul>
&lt;h2 id="api-key-替代方案">API Key 替代方案&lt;/h2>
&lt;p>如果不需要 username/password 的雙因素，單一 API key 更簡單。&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">X-API-Key: sk_monitor_abc123...&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>API key 的管理比 basic auth 簡單（一個字串而非帳密對），但安全性略低（只有一個 factor）。自用工具場景下 API key 通常足夠。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>SDK 端的 redaction → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/sdk-redaction-api/" data-link-title="SDK Redaction API 設計" data-link-desc="預設 redaction rule 過濾已知敏感欄位、自訂 pattern 擴展應用特有的 secret 格式 — redaction 在 SDK 端執行，敏感資料不離開 client">SDK Redaction API 設計&lt;/a>&lt;/li>
&lt;li>Collector 端的 access control → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control 實作&lt;/a>&lt;/li>
&lt;li>Server-side 的 secret management → &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 07 資安&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Transport 安全保護監控資料在從 SDK 傳送到 collector 的過程中不被竊聽或篡改。即使 SDK 端做了 redaction，傳輸中的資料仍然包含使用者行為、系統狀態、error 訊息等有價值的資訊 — 這些資訊在未加密的傳輸中可以被同網段的任何人攔截。</p>
<h2 id="同區網也要加密的理由">同區網也要加密的理由</h2>
<p>自用工具的 SDK 和 collector 通常在同一台機器或同一個區域網路（LAN / Tailscale tailnet）。常見的假設是「同區網不需要加密，因為只有我自己在用」。</p>
<p>這個假設在以下情境不成立：</p>
<p><strong>共用網路</strong>：咖啡廳、共享辦公室、飯店 WiFi — 同一個 AP 下的其他裝置可以用 ARP spoofing 或 WiFi sniffing 攔截未加密的 HTTP 流量。</p>
<p><strong>未來的網路拓撲變更</strong>：目前在同一台機器上的 SDK 和 collector，可能之後拆到不同的機器或不同的網路段。如果一開始就用 HTTPS，拓撲變更不需要額外的安全調整。</p>
<p><strong>養成正確習慣</strong>：在自用工具上用 HTTP 是因為「反正只有我」，但相同的開發者在商業專案中可能延續這個習慣。從自用工具開始就用 HTTPS，讓加密傳輸成為預設行為。</p>
<h2 id="https-設定">HTTPS 設定</h2>
<h3 id="自簽憑證">自簽憑證</h3>
<p>自用工具和內部服務用自簽憑證（self-signed certificate）就足夠。不需要購買 CA 憑證 — 自簽憑證提供加密（防竊聽）和完整性（防篡改），只是不提供身份驗證（client 無法確認 server 是不是「官方的」）。在自用場景中 server 就是自己架的，身份驗證不是問題。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days <span class="m">365</span> -nodes</span></span></code></pre></div><p>Go collector 使用自簽憑證：</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="nx">http</span><span class="p">.</span><span class="nf">ListenAndServeTLS</span><span class="p">(</span><span class="s">&#34;:8443&#34;</span><span class="p">,</span> <span class="s">&#34;cert.pem&#34;</span><span class="p">,</span> <span class="s">&#34;key.pem&#34;</span><span class="p">,</span> <span class="nx">handler</span><span class="p">)</span></span></span></code></pre></div><p>SDK 端需要信任自簽憑證。開發期可以在 HTTP client 設定 <code>badCertificateCallback</code> 接受自簽憑證；production 應該把自簽憑證加入系統的信任清單。</p>
<h3 id="lets-encrypt">Let&rsquo;s Encrypt</h3>
<p>如果 collector 有公開的 domain name，用 Let&rsquo;s Encrypt 取得免費的 CA 憑證。自動續期、不需要手動管理。適合部署在 VPS 或雲端的 collector。</p>
<h2 id="basic-auth">Basic Auth</h2>
<p>HTTPS 保護傳輸層（防竊聽），basic auth 保護 endpoint 層（防未授權存取）。兩者互補，缺一不可 — basic auth 在 HTTP 上傳送的是 base64 編碼的帳密，沒有 HTTPS 的加密保護等於明文傳送。</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">Authorization: Basic base64(username:password)</span></span></code></pre></div><p>SDK 在每個 HTTP POST request 的 header 中帶上 basic auth。Collector 端驗證帳密，不匹配則回傳 401。</p>
<p>Basic auth 的帳密管理：</p>
<ul>
<li>帳密存在 SDK 的設定檔或環境變數中，不硬編碼在程式碼裡</li>
<li>Collector 端的帳密用 bcrypt hash 儲存，不存明文</li>
<li>定期輪替帳密（自用工具半年到一年一次即可）</li>
</ul>
<h2 id="api-key-替代方案">API Key 替代方案</h2>
<p>如果不需要 username/password 的雙因素，單一 API key 更簡單。</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">X-API-Key: sk_monitor_abc123...</span></span></code></pre></div><p>API key 的管理比 basic auth 簡單（一個字串而非帳密對），但安全性略低（只有一個 factor）。自用工具場景下 API key 通常足夠。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>SDK 端的 redaction → <a href="/blog/monitoring/07-security-privacy/sdk-redaction-api/" data-link-title="SDK Redaction API 設計" data-link-desc="預設 redaction rule 過濾已知敏感欄位、自訂 pattern 擴展應用特有的 secret 格式 — redaction 在 SDK 端執行，敏感資料不離開 client">SDK Redaction API 設計</a></li>
<li>Collector 端的 access control → <a href="/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control 實作</a></li>
<li>Server-side 的 secret management → <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 07 資安</a></li>
</ul>
]]></content:encoded></item><item><title>自動攔截機制</title><link>https://tarrragon.github.io/blog/monitoring/03-sdk-design/auto-intercept/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/03-sdk-design/auto-intercept/</guid><description>&lt;p>自動攔截機制讓 SDK 在開發者不寫任何 error 上報程式碼的情況下，自動捕獲未處理的例外並記錄為 error 事件。每個平台有各自的全域錯誤處理器，SDK 在 init 時註冊攔截器，捕獲後轉換為統一的 error 事件格式送出。&lt;/p>
&lt;h2 id="各平台的攔截點">各平台的攔截點&lt;/h2>
&lt;h3 id="javascript--typescript">JavaScript / TypeScript&lt;/h3>
&lt;p>JS 環境有兩個全域錯誤攔截點：&lt;/p>
&lt;p>&lt;code>window.onerror&lt;/code> 捕獲同步程式碼中未處理的例外。回呼函式收到 error message、來源 URL、行號、列號和 Error 物件。&lt;/p>
&lt;p>&lt;code>window.onunhandledrejection&lt;/code> 捕獲未處理的 Promise rejection。回呼函式收到 PromiseRejectionEvent，包含 rejection reason。&lt;/p>
&lt;p>SDK 在 init 時註冊這兩個處理器。註冊前先保存原有的處理器（如果有），攔截後先呼叫原有處理器再執行 SDK 的記錄邏輯 — 避免覆蓋應用程式已有的錯誤處理。&lt;/p>
&lt;p>限制：&lt;code>onerror&lt;/code> 對跨域腳本的錯誤只收到 &lt;code>Script error.&lt;/code> 訊息，沒有 stack trace。需要在 &lt;code>&amp;lt;script&amp;gt;&lt;/code> 標籤加 &lt;code>crossorigin&lt;/code> 屬性，server 端的 CORS header 加 &lt;code>Access-Control-Allow-Origin&lt;/code>。&lt;/p>
&lt;h3 id="flutter">Flutter&lt;/h3>
&lt;p>Flutter 有兩個攔截層：&lt;/p>
&lt;p>&lt;code>FlutterError.onError&lt;/code> 捕獲 widget build / layout / paint 過程中的例外。預設行為是在 console 印出錯誤，SDK 替換為記錄 error 事件後再呼叫預設處理器。&lt;/p>
&lt;p>&lt;code>PlatformDispatcher.instance.onError&lt;/code> 捕獲其他非同步區域的未處理例外（Dart 2.15+）。包含 Isolate 內的未捕獲例外。&lt;/p>
&lt;p>&lt;code>runZonedGuarded&lt;/code> 是另一個選項 — 在指定的 Zone 內捕獲所有未處理例外。SDK 可以用 &lt;code>runZonedGuarded&lt;/code> 包住整個 &lt;code>runApp()&lt;/code>，但這和 &lt;code>PlatformDispatcher.onError&lt;/code> 有重疊，需要避免同一個例外被記錄兩次。&lt;/p>
&lt;p>限制：Flutter 的 release mode 會移除 stack trace 的符號資訊（obfuscation）。需要保留 debug symbols 檔案（&lt;code>.dSYM&lt;/code> / &lt;code>mapping.txt&lt;/code>），在 collector 端做 symbolication。&lt;/p>
&lt;h3 id="python">Python&lt;/h3>
&lt;p>&lt;code>sys.excepthook&lt;/code> 處理主執行緒的未捕獲例外。回呼函式收到 exception type、value 和 traceback。&lt;/p>
&lt;p>&lt;code>threading.excepthook&lt;/code>（Python 3.8+）處理子執行緒的未捕獲例外。&lt;/p>
&lt;p>&lt;code>atexit.register&lt;/code> 用於在 Python 程序退出時 flush 剩餘的 buffer。但 &lt;code>atexit&lt;/code> 在 &lt;code>os._exit()&lt;/code> 或 SIGKILL 時不會執行。&lt;/p>
&lt;p>限制：Python 的 GIL 讓 SDK 的網路操作可能阻塞主執行緒。SDK 的 flush 應該在獨立的 daemon thread 中執行，主執行緒只負責把事件放入 buffer。&lt;/p>
&lt;h2 id="攔截後的統一處理">攔截後的統一處理&lt;/h2>
&lt;p>不同平台的錯誤物件格式不同（JS 的 Error、Flutter 的 FlutterErrorDetails、Python 的 sys.exc_info tuple）。SDK 在攔截後把平台特定的錯誤物件轉換為統一的 error 事件格式：&lt;/p>
&lt;ul>
&lt;li>type: &lt;code>&amp;quot;error&amp;quot;&lt;/code>&lt;/li>
&lt;li>name: 從 error class name 推導（&lt;code>TypeError&lt;/code> → &lt;code>error.TypeError&lt;/code>）&lt;/li>
&lt;li>data: 包含 message、stack trace（字串化）、觸發位置&lt;/li>
&lt;/ul>
&lt;p>轉換層是每個平台 SDK 唯一的平台特定程式碼。轉換完成後，事件進入和手動上報相同的 buffer → flush 管線。&lt;/p>
&lt;h2 id="和手動上報的分工">和手動上報的分工&lt;/h2>
&lt;p>自動攔截處理「開發者沒有預期到的錯誤」— 未捕獲的例外、未處理的 rejection。手動上報（&lt;code>Monitor.error()&lt;/code>）處理「開發者知道可能發生但想記錄的錯誤」— 已捕獲的例外、業務邏輯的異常狀態。&lt;/p>
&lt;p>兩者進入同一個 buffer 和 flush 管線，在 collector 端可以用 data 中的 &lt;code>source: &amp;quot;auto&amp;quot;&lt;/code> / &lt;code>source: &amp;quot;manual&amp;quot;&lt;/code> 欄位區分。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>SDK 公開 API → &lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/public-api/" data-link-title="SDK 公開 API 設計" data-link-desc="init / event / error / metric / flush / close 六個方法構成 SDK 的完整生命週期 — 跨平台共用相同 API 介面">SDK 公開 API 設計&lt;/a>&lt;/li>
&lt;li>各平台的深入適配問題 → &lt;a href="https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/" data-link-title="模組五：平台適配" data-link-desc="JS CORS / Flutter isolate / Python GIL / Go graceful shutdown — 各平台的特殊考量">模組五 平台適配&lt;/a>&lt;/li>
&lt;li>Buffer 和 flush → &lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/batch-flush/" data-link-title="攢批送出策略" data-link-desc="flush interval / buffer size / flush on close 三個控制點決定事件何時離開 SDK — 平衡即時性和網路效率">攢批送出策略&lt;/a>&lt;/li>
&lt;li>主動感測器設計（和被動攔截互補）→ &lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/frontend-sensor-design/" data-link-title="前端感測器設計" data-link-desc="什麼行為值得埋感測器、每類感測器的實作方式、取樣策略和效能影響 — 和 auto-intercept 的被動攔截互補">前端感測器設計&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>自動攔截機制讓 SDK 在開發者不寫任何 error 上報程式碼的情況下，自動捕獲未處理的例外並記錄為 error 事件。每個平台有各自的全域錯誤處理器，SDK 在 init 時註冊攔截器，捕獲後轉換為統一的 error 事件格式送出。</p>
<h2 id="各平台的攔截點">各平台的攔截點</h2>
<h3 id="javascript--typescript">JavaScript / TypeScript</h3>
<p>JS 環境有兩個全域錯誤攔截點：</p>
<p><code>window.onerror</code> 捕獲同步程式碼中未處理的例外。回呼函式收到 error message、來源 URL、行號、列號和 Error 物件。</p>
<p><code>window.onunhandledrejection</code> 捕獲未處理的 Promise rejection。回呼函式收到 PromiseRejectionEvent，包含 rejection reason。</p>
<p>SDK 在 init 時註冊這兩個處理器。註冊前先保存原有的處理器（如果有），攔截後先呼叫原有處理器再執行 SDK 的記錄邏輯 — 避免覆蓋應用程式已有的錯誤處理。</p>
<p>限制：<code>onerror</code> 對跨域腳本的錯誤只收到 <code>Script error.</code> 訊息，沒有 stack trace。需要在 <code>&lt;script&gt;</code> 標籤加 <code>crossorigin</code> 屬性，server 端的 CORS header 加 <code>Access-Control-Allow-Origin</code>。</p>
<h3 id="flutter">Flutter</h3>
<p>Flutter 有兩個攔截層：</p>
<p><code>FlutterError.onError</code> 捕獲 widget build / layout / paint 過程中的例外。預設行為是在 console 印出錯誤，SDK 替換為記錄 error 事件後再呼叫預設處理器。</p>
<p><code>PlatformDispatcher.instance.onError</code> 捕獲其他非同步區域的未處理例外（Dart 2.15+）。包含 Isolate 內的未捕獲例外。</p>
<p><code>runZonedGuarded</code> 是另一個選項 — 在指定的 Zone 內捕獲所有未處理例外。SDK 可以用 <code>runZonedGuarded</code> 包住整個 <code>runApp()</code>，但這和 <code>PlatformDispatcher.onError</code> 有重疊，需要避免同一個例外被記錄兩次。</p>
<p>限制：Flutter 的 release mode 會移除 stack trace 的符號資訊（obfuscation）。需要保留 debug symbols 檔案（<code>.dSYM</code> / <code>mapping.txt</code>），在 collector 端做 symbolication。</p>
<h3 id="python">Python</h3>
<p><code>sys.excepthook</code> 處理主執行緒的未捕獲例外。回呼函式收到 exception type、value 和 traceback。</p>
<p><code>threading.excepthook</code>（Python 3.8+）處理子執行緒的未捕獲例外。</p>
<p><code>atexit.register</code> 用於在 Python 程序退出時 flush 剩餘的 buffer。但 <code>atexit</code> 在 <code>os._exit()</code> 或 SIGKILL 時不會執行。</p>
<p>限制：Python 的 GIL 讓 SDK 的網路操作可能阻塞主執行緒。SDK 的 flush 應該在獨立的 daemon thread 中執行，主執行緒只負責把事件放入 buffer。</p>
<h2 id="攔截後的統一處理">攔截後的統一處理</h2>
<p>不同平台的錯誤物件格式不同（JS 的 Error、Flutter 的 FlutterErrorDetails、Python 的 sys.exc_info tuple）。SDK 在攔截後把平台特定的錯誤物件轉換為統一的 error 事件格式：</p>
<ul>
<li>type: <code>&quot;error&quot;</code></li>
<li>name: 從 error class name 推導（<code>TypeError</code> → <code>error.TypeError</code>）</li>
<li>data: 包含 message、stack trace（字串化）、觸發位置</li>
</ul>
<p>轉換層是每個平台 SDK 唯一的平台特定程式碼。轉換完成後，事件進入和手動上報相同的 buffer → flush 管線。</p>
<h2 id="和手動上報的分工">和手動上報的分工</h2>
<p>自動攔截處理「開發者沒有預期到的錯誤」— 未捕獲的例外、未處理的 rejection。手動上報（<code>Monitor.error()</code>）處理「開發者知道可能發生但想記錄的錯誤」— 已捕獲的例外、業務邏輯的異常狀態。</p>
<p>兩者進入同一個 buffer 和 flush 管線，在 collector 端可以用 data 中的 <code>source: &quot;auto&quot;</code> / <code>source: &quot;manual&quot;</code> 欄位區分。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>SDK 公開 API → <a href="/blog/monitoring/03-sdk-design/public-api/" data-link-title="SDK 公開 API 設計" data-link-desc="init / event / error / metric / flush / close 六個方法構成 SDK 的完整生命週期 — 跨平台共用相同 API 介面">SDK 公開 API 設計</a></li>
<li>各平台的深入適配問題 → <a href="/blog/monitoring/05-platform-adaptation/" data-link-title="模組五：平台適配" data-link-desc="JS CORS / Flutter isolate / Python GIL / Go graceful shutdown — 各平台的特殊考量">模組五 平台適配</a></li>
<li>Buffer 和 flush → <a href="/blog/monitoring/03-sdk-design/batch-flush/" data-link-title="攢批送出策略" data-link-desc="flush interval / buffer size / flush on close 三個控制點決定事件何時離開 SDK — 平衡即時性和網路效率">攢批送出策略</a></li>
<li>主動感測器設計（和被動攔截互補）→ <a href="/blog/monitoring/03-sdk-design/frontend-sensor-design/" data-link-title="前端感測器設計" data-link-desc="什麼行為值得埋感測器、每類感測器的實作方式、取樣策略和效能影響 — 和 auto-intercept 的被動攔截互補">前端感測器設計</a></li>
</ul>
]]></content:encoded></item><item><title>事件命名規範</title><link>https://tarrragon.github.io/blog/monitoring/01-mental-model/event-naming-convention/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/01-mental-model/event-naming-convention/</guid><description>&lt;p>事件命名的目的是讓事件可以被 grep、過濾和統計。統一的命名規範讓不同時期、不同開發者加入的事件能在同一個查詢框架中使用。&lt;/p>
&lt;h2 id="namespaceaction-格式">namespace.action 格式&lt;/h2>
&lt;p>每個事件名稱由兩部分組成：namespace（事件發生的模組或功能區域）和 action（發生了什麼）。用 &lt;code>.&lt;/code> 分隔。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">terminal.connect.start ← namespace: terminal.connect, action: start
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">terminal.connect.done ← namespace: terminal.connect, action: &lt;span class="k">done&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">terminal.input.submit ← namespace: terminal.input, action: submit
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">auth.biometric.success ← namespace: auth.biometric, action: success
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">auth.biometric.fallback ← namespace: auth.biometric, action: fallback
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">enrollment.qr.scan ← namespace: enrollment.qr, action: scan&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="namespace-層級">Namespace 層級&lt;/h3>
&lt;p>Namespace 的層級深度依功能結構而定。兩層通常足夠（&lt;code>terminal.connect&lt;/code>），三層用於需要進一步區分的場景（&lt;code>terminal.connect.ws&lt;/code>）。超過三層通常代表 namespace 設計過細，增加認知成本但不增加分析價值。&lt;/p>
&lt;h3 id="action-命名">Action 命名&lt;/h3>
&lt;p>Action 使用動詞（&lt;code>start&lt;/code>、&lt;code>submit&lt;/code>、&lt;code>scan&lt;/code>）或狀態（&lt;code>success&lt;/code>、&lt;code>failed&lt;/code>、&lt;code>timeout&lt;/code>）。同一組動作用配對的 action 名稱：&lt;code>start&lt;/code> / &lt;code>done&lt;/code>（成對的生命週期）、&lt;code>success&lt;/code> / &lt;code>failed&lt;/code>（結果分支）。&lt;/p>
&lt;p>避免在 action 中重複 namespace 的資訊。&lt;code>terminal.connect.terminal_connected&lt;/code> 中 &lt;code>terminal&lt;/code> 重複了；&lt;code>terminal.connect.done&lt;/code> 更簡潔。&lt;/p>
&lt;h2 id="命名一致性的工程價值">命名一致性的工程價值&lt;/h2>
&lt;h3 id="grep-友好">Grep 友好&lt;/h3>
&lt;p>統一的 namespace 結構讓開發者用 &lt;code>grep &amp;quot;terminal.connect&amp;quot;&lt;/code> 就能找到所有連線相關事件，不需要知道每個事件的完整名稱。&lt;/p>
&lt;h3 id="統計友好">統計友好&lt;/h3>
&lt;p>按 namespace 前綴分群統計。&lt;code>terminal.*&lt;/code> 的事件數量 = terminal 功能的使用頻率；&lt;code>auth.*&lt;/code> 的事件數量 = 認證觸發頻率。層級結構讓統計的粒度可以調整。&lt;/p>
&lt;h3 id="文件友好">文件友好&lt;/h3>
&lt;p>事件清單按 namespace 排列就是一份結構化的功能地圖。新加入的開發者讀事件清單就能理解系統有哪些功能模組。&lt;/p>
&lt;h2 id="和商業方案的命名對應">和商業方案的命名對應&lt;/h2>
&lt;p>不同的商業監控方案有各自的命名慣例。自架方案用 namespace.action 格式，接入商業方案時需要做對應。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>商業方案&lt;/th>
 &lt;th>命名慣例&lt;/th>
 &lt;th>對應方式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>GA4&lt;/td>
 &lt;td>&lt;code>event_name&lt;/code> + parameters&lt;/td>
 &lt;td>namespace.action → &lt;code>event_name&lt;/code>，細節放 parameters&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sentry&lt;/td>
 &lt;td>transaction name + spans&lt;/td>
 &lt;td>namespace → transaction，action → span&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Mixpanel&lt;/td>
 &lt;td>event name + properties&lt;/td>
 &lt;td>namespace.action → event name&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Datadog RUM&lt;/td>
 &lt;td>action name + view name&lt;/td>
 &lt;td>action → action name，namespace → view&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>對應時保持一個原則：自架方案的事件名稱是 source of truth，商業方案的名稱是它的映射。在自架方案中改名後，映射層跟著改；不要讓商業方案的命名反過來影響自架的命名結構。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>四類事件的定義 → &lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/four-event-types/" data-link-title="四類事件的完整定義" data-link-desc="Event / Error / Metric / Lifecycle 四類事件各自的語意、觸發時機和典型用途 — 分類是監控體系的統一語言">四類事件的完整定義&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>商業方案的完整比較 → &lt;a href="https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/" data-link-title="模組六：商業方案對照" data-link-desc="Sentry / Crashlytics / Datadog RUM / Mixpanel — 自架 vs 商業的功能和成本取捨">模組六 商業方案比較&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>事件命名的目的是讓事件可以被 grep、過濾和統計。統一的命名規範讓不同時期、不同開發者加入的事件能在同一個查詢框架中使用。</p>
<h2 id="namespaceaction-格式">namespace.action 格式</h2>
<p>每個事件名稱由兩部分組成：namespace（事件發生的模組或功能區域）和 action（發生了什麼）。用 <code>.</code> 分隔。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">terminal.connect.start      ← namespace: terminal.connect, action: start
</span></span><span class="line"><span class="ln">2</span><span class="cl">terminal.connect.done       ← namespace: terminal.connect, action: <span class="k">done</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">terminal.input.submit       ← namespace: terminal.input, action: submit
</span></span><span class="line"><span class="ln">4</span><span class="cl">auth.biometric.success      ← namespace: auth.biometric, action: success
</span></span><span class="line"><span class="ln">5</span><span class="cl">auth.biometric.fallback     ← namespace: auth.biometric, action: fallback
</span></span><span class="line"><span class="ln">6</span><span class="cl">enrollment.qr.scan          ← namespace: enrollment.qr, action: scan</span></span></code></pre></div><h3 id="namespace-層級">Namespace 層級</h3>
<p>Namespace 的層級深度依功能結構而定。兩層通常足夠（<code>terminal.connect</code>），三層用於需要進一步區分的場景（<code>terminal.connect.ws</code>）。超過三層通常代表 namespace 設計過細，增加認知成本但不增加分析價值。</p>
<h3 id="action-命名">Action 命名</h3>
<p>Action 使用動詞（<code>start</code>、<code>submit</code>、<code>scan</code>）或狀態（<code>success</code>、<code>failed</code>、<code>timeout</code>）。同一組動作用配對的 action 名稱：<code>start</code> / <code>done</code>（成對的生命週期）、<code>success</code> / <code>failed</code>（結果分支）。</p>
<p>避免在 action 中重複 namespace 的資訊。<code>terminal.connect.terminal_connected</code> 中 <code>terminal</code> 重複了；<code>terminal.connect.done</code> 更簡潔。</p>
<h2 id="命名一致性的工程價值">命名一致性的工程價值</h2>
<h3 id="grep-友好">Grep 友好</h3>
<p>統一的 namespace 結構讓開發者用 <code>grep &quot;terminal.connect&quot;</code> 就能找到所有連線相關事件，不需要知道每個事件的完整名稱。</p>
<h3 id="統計友好">統計友好</h3>
<p>按 namespace 前綴分群統計。<code>terminal.*</code> 的事件數量 = terminal 功能的使用頻率；<code>auth.*</code> 的事件數量 = 認證觸發頻率。層級結構讓統計的粒度可以調整。</p>
<h3 id="文件友好">文件友好</h3>
<p>事件清單按 namespace 排列就是一份結構化的功能地圖。新加入的開發者讀事件清單就能理解系統有哪些功能模組。</p>
<h2 id="和商業方案的命名對應">和商業方案的命名對應</h2>
<p>不同的商業監控方案有各自的命名慣例。自架方案用 namespace.action 格式，接入商業方案時需要做對應。</p>
<table>
  <thead>
      <tr>
          <th>商業方案</th>
          <th>命名慣例</th>
          <th>對應方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GA4</td>
          <td><code>event_name</code> + parameters</td>
          <td>namespace.action → <code>event_name</code>，細節放 parameters</td>
      </tr>
      <tr>
          <td>Sentry</td>
          <td>transaction name + spans</td>
          <td>namespace → transaction，action → span</td>
      </tr>
      <tr>
          <td>Mixpanel</td>
          <td>event name + properties</td>
          <td>namespace.action → event name</td>
      </tr>
      <tr>
          <td>Datadog RUM</td>
          <td>action name + view name</td>
          <td>action → action name，namespace → view</td>
      </tr>
  </tbody>
</table>
<p>對應時保持一個原則：自架方案的事件名稱是 source of truth，商業方案的名稱是它的映射。在自架方案中改名後，映射層跟著改；不要讓商業方案的命名反過來影響自架的命名結構。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>四類事件的定義 → <a href="/blog/monitoring/01-mental-model/four-event-types/" data-link-title="四類事件的完整定義" data-link-desc="Event / Error / Metric / Lifecycle 四類事件各自的語意、觸發時機和典型用途 — 分類是監控體系的統一語言">四類事件的完整定義</a></li>
<li>從需求推導收集策略 → <a href="/blog/monitoring/01-mental-model/derive-collection-from-requirements/" data-link-title="從需求推導「該收集哪些事件」" data-link-desc="從 debug 需求、行為分析需求、效能需求、合規需求四個方向推導事件收集策略 — 避免「什麼都收」和「什麼都不收」">從需求推導「該收集哪些事件」</a></li>
<li>商業方案的完整比較 → <a href="/blog/monitoring/06-commercial-comparison/" data-link-title="模組六：商業方案對照" data-link-desc="Sentry / Crashlytics / Datadog RUM / Mixpanel — 自架 vs 商業的功能和成本取捨">模組六 商業方案比較</a></li>
</ul>
]]></content:encoded></item><item><title>模組二：Log Schema 設計</title><link>https://tarrragon.github.io/blog/monitoring/02-log-schema/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/02-log-schema/</guid><description>&lt;p>回答「事件長什麼樣」。schema 是所有 SDK 和 collector 的契約 SOT。&lt;/p>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> event.schema.json 完整欄位解說&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 欄位設計原則（source 標明來源 / data 自由欄位 / v 版本演進）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Schema 版本演進策略（backward compatible 的增量變更）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 跟 OpenTelemetry 的 schema 差異對照&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>SOT repo：&lt;a href="https://github.com/tarrragon/monitor">tarrragon/monitor&lt;/a> 的 &lt;code>schema/event.schema.json&lt;/code>&lt;/li>
&lt;li>← &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>：log 點設計產出的事件需符合本 schema&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安&lt;/a>：schema 中哪些欄位需要 redaction&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「事件長什麼樣」。schema 是所有 SDK 和 collector 的契約 SOT。</p>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> event.schema.json 完整欄位解說</li>
<li><input checked="" disabled="" type="checkbox"> 欄位設計原則（source 標明來源 / data 自由欄位 / v 版本演進）</li>
<li><input checked="" disabled="" type="checkbox"> Schema 版本演進策略（backward compatible 的增量變更）</li>
<li><input checked="" disabled="" type="checkbox"> 跟 OpenTelemetry 的 schema 差異對照</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>SOT repo：<a href="https://github.com/tarrragon/monitor">tarrragon/monitor</a> 的 <code>schema/event.schema.json</code></li>
<li>← <a href="/blog/testing/02-client-observability/" data-link-title="模組二：客戶端可觀測性" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — log 設計是功能規格的一部分">testing 模組二</a>：log 點設計產出的事件需符合本 schema</li>
<li>→ <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a>：schema 中哪些欄位需要 redaction</li>
</ul>
]]></content:encoded></item><item><title>欄位設計原則</title><link>https://tarrragon.github.io/blog/monitoring/02-log-schema/field-design-principles/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/02-log-schema/field-design-principles/</guid><description>&lt;p>事件 schema 的欄位設計遵循三個原則：來源可追溯、擴展不破壞、版本可辨識。這三個原則讓 schema 從自用工具的 grep 查詢一直到商業方案的資料管線都能正常運作。&lt;/p>
&lt;h2 id="原則一source-標明來源">原則一：source 標明來源&lt;/h2>
&lt;p>每筆事件的 source 欄位記錄「這筆事件從哪裡來」。App 名稱、版本、平台、OS 版本 — 這些資訊在事件產生時由 SDK 自動填入，不依賴使用者或開發者手動標記。&lt;/p>
&lt;p>source 的設計要點是「足夠區分但不過度」。&lt;code>sdk&lt;/code> 和 &lt;code>platform&lt;/code> 是必填——sdk 標明事件由哪個 SDK 實作產生（&lt;code>js&lt;/code> / &lt;code>flutter&lt;/code> / &lt;code>python&lt;/code> / &lt;code>go&lt;/code>），platform 標明運行平台（&lt;code>ios&lt;/code> / &lt;code>android&lt;/code> / &lt;code>web&lt;/code> / &lt;code>macos&lt;/code>）。兩者不能互相推導：同一個 platform（iOS）上可能有不同的 SDK（Flutter SDK 或 Swift 原生 SDK），同一個 SDK（Flutter）可能跑在不同 platform（iOS / Android / Web）。App 名稱和版本能區分「這是哪個 app 的哪個版本送來的事件」。OS 版本用於分析平台特定的問題（「這個 error 只出現在 iOS 17.4」）。&lt;/p>
&lt;p>不需要在 source 放裝置 ID 或使用者 ID — 這些屬於個人識別資訊，放在 source 會讓每一筆事件都攜帶 PII，增加去識別化的複雜度。Session ID 用於關聯同次使用的事件，已足夠取代裝置/使用者級別的追蹤。&lt;/p>
&lt;h2 id="原則二data-自由欄位">原則二：data 自由欄位&lt;/h2>
&lt;p>data 欄位是事件的附加資料區域，接受任意 JSON object。核心欄位（type、name、timestamp、source）有固定的 schema 驗證，data 的內容不做 schema 驗證（或做寬鬆驗證）。&lt;/p>
&lt;p>自由欄位的設計理由是「不同事件需要不同的附加資料」。&lt;code>terminal.connect.done&lt;/code> 需要 URL 和 duration；&lt;code>auth.biometric.failed&lt;/code> 需要 error code 和 fallback 方式。為每種事件定義固定的 data schema 會讓 schema 膨脹且頻繁變動。&lt;/p>
&lt;p>自由的代價是查詢時無法保證 data 內某個欄位一定存在。處理策略：查詢時用 optional access（&lt;code>data?.duration_ms&lt;/code>），統計時跳過缺少目標欄位的事件。&lt;/p>
&lt;h2 id="原則三v-版本演進">原則三：v 版本演進&lt;/h2>
&lt;p>v 欄位是整數版本號，標明「這筆事件是用哪個版本的 schema 產生的」。&lt;/p>
&lt;p>版本號解決的問題是 schema 變更時的向後相容。新版本的 SDK 產生 v=2 的事件，舊版本的 SDK 仍在產生 v=1 的事件。Collector 收到事件時根據 v 決定用哪個版本的驗證和處理邏輯。&lt;/p>
&lt;p>版本號的遞增規則：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>新增選填欄位&lt;/strong>：不需要遞增版本號。舊版事件缺少新欄位，collector 用預設值處理。&lt;/li>
&lt;li>&lt;strong>新增必填欄位&lt;/strong>：遞增版本號。舊版事件沒有這個欄位，collector 需要區分版本處理。&lt;/li>
&lt;li>&lt;strong>刪除或改名欄位&lt;/strong>：遞增版本號。collector 需要同時支援新舊版本的事件格式。&lt;/li>
&lt;li>&lt;strong>改變欄位型別&lt;/strong>：遞增版本號。string 改成 integer 等型別變更需要不同的解析邏輯。&lt;/li>
&lt;/ul>
&lt;h2 id="欄位命名慣例">欄位命名慣例&lt;/h2>
&lt;p>欄位名稱使用 snake_case（&lt;code>duration_ms&lt;/code>、&lt;code>error_code&lt;/code>），和 JSON 的慣例一致。避免在欄位名稱中編碼單位（&lt;code>duration&lt;/code> 不夠明確 — 是秒還是毫秒？），在名稱中加上單位後綴（&lt;code>duration_ms&lt;/code>、&lt;code>size_bytes&lt;/code>）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>完整欄位定義 → &lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">event.schema.json 完整欄位解說&lt;/a>&lt;/li>
&lt;li>Schema 版本演進的具體策略 → &lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/schema-versioning/" data-link-title="Schema 版本演進策略" data-link-desc="Backward compatible 的增量變更 — 新增欄位不改版、改名或改型別才改版、collector 同時支援多版本">Schema 版本演進策略&lt;/a>&lt;/li>
&lt;li>和 OpenTelemetry 的比較 → &lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/otel-comparison/" data-link-title="跟 OpenTelemetry 的 schema 差異對照" data-link-desc="自架 event schema 和 OTLP 的設計差異 — 為什麼 client-side 監控用簡化 schema、什麼時候切換到 OTLP">跟 OpenTelemetry 的 schema 差異對照&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>事件 schema 的欄位設計遵循三個原則：來源可追溯、擴展不破壞、版本可辨識。這三個原則讓 schema 從自用工具的 grep 查詢一直到商業方案的資料管線都能正常運作。</p>
<h2 id="原則一source-標明來源">原則一：source 標明來源</h2>
<p>每筆事件的 source 欄位記錄「這筆事件從哪裡來」。App 名稱、版本、平台、OS 版本 — 這些資訊在事件產生時由 SDK 自動填入，不依賴使用者或開發者手動標記。</p>
<p>source 的設計要點是「足夠區分但不過度」。<code>sdk</code> 和 <code>platform</code> 是必填——sdk 標明事件由哪個 SDK 實作產生（<code>js</code> / <code>flutter</code> / <code>python</code> / <code>go</code>），platform 標明運行平台（<code>ios</code> / <code>android</code> / <code>web</code> / <code>macos</code>）。兩者不能互相推導：同一個 platform（iOS）上可能有不同的 SDK（Flutter SDK 或 Swift 原生 SDK），同一個 SDK（Flutter）可能跑在不同 platform（iOS / Android / Web）。App 名稱和版本能區分「這是哪個 app 的哪個版本送來的事件」。OS 版本用於分析平台特定的問題（「這個 error 只出現在 iOS 17.4」）。</p>
<p>不需要在 source 放裝置 ID 或使用者 ID — 這些屬於個人識別資訊，放在 source 會讓每一筆事件都攜帶 PII，增加去識別化的複雜度。Session ID 用於關聯同次使用的事件，已足夠取代裝置/使用者級別的追蹤。</p>
<h2 id="原則二data-自由欄位">原則二：data 自由欄位</h2>
<p>data 欄位是事件的附加資料區域，接受任意 JSON object。核心欄位（type、name、timestamp、source）有固定的 schema 驗證，data 的內容不做 schema 驗證（或做寬鬆驗證）。</p>
<p>自由欄位的設計理由是「不同事件需要不同的附加資料」。<code>terminal.connect.done</code> 需要 URL 和 duration；<code>auth.biometric.failed</code> 需要 error code 和 fallback 方式。為每種事件定義固定的 data schema 會讓 schema 膨脹且頻繁變動。</p>
<p>自由的代價是查詢時無法保證 data 內某個欄位一定存在。處理策略：查詢時用 optional access（<code>data?.duration_ms</code>），統計時跳過缺少目標欄位的事件。</p>
<h2 id="原則三v-版本演進">原則三：v 版本演進</h2>
<p>v 欄位是整數版本號，標明「這筆事件是用哪個版本的 schema 產生的」。</p>
<p>版本號解決的問題是 schema 變更時的向後相容。新版本的 SDK 產生 v=2 的事件，舊版本的 SDK 仍在產生 v=1 的事件。Collector 收到事件時根據 v 決定用哪個版本的驗證和處理邏輯。</p>
<p>版本號的遞增規則：</p>
<ul>
<li><strong>新增選填欄位</strong>：不需要遞增版本號。舊版事件缺少新欄位，collector 用預設值處理。</li>
<li><strong>新增必填欄位</strong>：遞增版本號。舊版事件沒有這個欄位，collector 需要區分版本處理。</li>
<li><strong>刪除或改名欄位</strong>：遞增版本號。collector 需要同時支援新舊版本的事件格式。</li>
<li><strong>改變欄位型別</strong>：遞增版本號。string 改成 integer 等型別變更需要不同的解析邏輯。</li>
</ul>
<h2 id="欄位命名慣例">欄位命名慣例</h2>
<p>欄位名稱使用 snake_case（<code>duration_ms</code>、<code>error_code</code>），和 JSON 的慣例一致。避免在欄位名稱中編碼單位（<code>duration</code> 不夠明確 — 是秒還是毫秒？），在名稱中加上單位後綴（<code>duration_ms</code>、<code>size_bytes</code>）。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>完整欄位定義 → <a href="/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">event.schema.json 完整欄位解說</a></li>
<li>Schema 版本演進的具體策略 → <a href="/blog/monitoring/02-log-schema/schema-versioning/" data-link-title="Schema 版本演進策略" data-link-desc="Backward compatible 的增量變更 — 新增欄位不改版、改名或改型別才改版、collector 同時支援多版本">Schema 版本演進策略</a></li>
<li>和 OpenTelemetry 的比較 → <a href="/blog/monitoring/02-log-schema/otel-comparison/" data-link-title="跟 OpenTelemetry 的 schema 差異對照" data-link-desc="自架 event schema 和 OTLP 的設計差異 — 為什麼 client-side 監控用簡化 schema、什麼時候切換到 OTLP">跟 OpenTelemetry 的 schema 差異對照</a></li>
</ul>
]]></content:encoded></item><item><title>Cohort Analysis</title><link>https://tarrragon.github.io/blog/monitoring/08-business-analytics/cohort-analysis/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/08-business-analytics/cohort-analysis/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/cohort-analysis/" data-link-title="Cohort Analysis" data-link-desc="說明把使用者按共同特徵分群、比較不同群組行為差異的分析方法">Cohort analysis&lt;/a> 把使用者按共同特徵分群（cohort），比較不同群體在同一個指標上的表現差異。整體平均留存率 40% 可能隱藏了「1 月註冊的使用者留存 60%、3 月註冊的留存 20%」的差異。Cohort analysis 揭露平均值遮蔽的趨勢。&lt;/p>
&lt;h2 id="cohort-的定義方式">Cohort 的定義方式&lt;/h2>
&lt;h3 id="時間-cohort最常用">時間 cohort（最常用）&lt;/h3>
&lt;p>按使用者完成某個動作的時間分群。「1 月份註冊的使用者」「第 12 週 onboarding 完成的使用者」。&lt;/p>
&lt;p>時間 cohort 回答的問題：產品的留存率是否隨時間改善？新版本上線後註冊的使用者留存是否比舊版本高？&lt;/p>
&lt;h3 id="行為-cohort">行為 cohort&lt;/h3>
&lt;p>按使用者的行為特徵分群。「首次使用就完成購買的使用者」「使用過搜尋功能的使用者」「連續 3 天登入的使用者」。&lt;/p>
&lt;p>行為 cohort 回答的問題：哪些行為和留存相關？做了 X 的使用者留存率是否比沒做 X 的高？&lt;/p>
&lt;h3 id="屬性-cohort">屬性 cohort&lt;/h3>
&lt;p>按使用者的固有屬性分群。「iOS 使用者」「企業方案使用者」「來自特定廣告渠道的使用者」。&lt;/p>
&lt;p>屬性 cohort 回答的問題：不同平台/方案/來源的使用者行為是否不同？&lt;/p>
&lt;h2 id="留存率矩陣">留存率矩陣&lt;/h2>
&lt;p>留存率矩陣是 cohort analysis 最常見的呈現方式。每行代表一個 cohort（例如某月註冊的使用者），每列代表註冊後的第 N 天/週/月，格中的值是該 cohort 在第 N 期仍活躍的比例。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Cohort&lt;/th>
 &lt;th>第 0 週&lt;/th>
 &lt;th>第 1 週&lt;/th>
 &lt;th>第 2 週&lt;/th>
 &lt;th>第 4 週&lt;/th>
 &lt;th>第 8 週&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1 月&lt;/td>
 &lt;td>100%&lt;/td>
 &lt;td>45%&lt;/td>
 &lt;td>32%&lt;/td>
 &lt;td>22%&lt;/td>
 &lt;td>18%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2 月&lt;/td>
 &lt;td>100%&lt;/td>
 &lt;td>48%&lt;/td>
 &lt;td>35%&lt;/td>
 &lt;td>25%&lt;/td>
 &lt;td>20%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3 月&lt;/td>
 &lt;td>100%&lt;/td>
 &lt;td>52%&lt;/td>
 &lt;td>40%&lt;/td>
 &lt;td>30%&lt;/td>
 &lt;td>—&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>從這張矩陣可以看到：留存率逐月改善（1 月 → 3 月的第 1 週留存從 45% 升到 52%）。如果 2 月有產品改版，這個改善可能和改版相關。&lt;/p>
&lt;h2 id="cohort-analysis-的判讀">Cohort analysis 的判讀&lt;/h2>
&lt;h3 id="自然衰減-vs-產品問題">自然衰減 vs 產品問題&lt;/h3>
&lt;p>所有產品都有自然衰減 — 使用者隨時間減少是正常的。Cohort analysis 的價值在於區分「正常衰減」和「異常衰減」。&lt;/p>
&lt;p>如果所有 cohort 的衰減曲線形狀相似，衰減是產品層面的結構性問題（例如缺少持續使用的理由）。如果某個 cohort 的衰減明顯比其他 cohort 快，需要調查該 cohort 的特殊情況（當時的產品版本、市場環境、使用者來源）。&lt;/p>
&lt;h3 id="穩態留存">穩態留存&lt;/h3>
&lt;p>留存率通常在某個時間點後趨於穩定 — 留下來的使用者不再大量流失。穩態留存的百分比和到達穩態的時間是產品健康度的核心指標。&lt;/p>
&lt;p>穩態留存高但到達時間長 = 產品有價值但 onboarding 需要改善。穩態留存低 = 產品的持續使用價值不足。&lt;/p>
&lt;h2 id="和-funnel-的關係">和 funnel 的關係&lt;/h2>
&lt;p>Funnel analysis 回答「使用者在哪一步流失」（單次流程），cohort analysis 回答「使用者是否持續回來」（長期行為）。兩者互補：funnel 改善單次流程的轉換率，cohort 追蹤改善是否帶來長期留存的變化。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>使用者從哪來 → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/attribution/" data-link-title="Attribution" data-link-desc="使用者從哪來、哪個渠道帶來轉換 — last-touch / first-touch / multi-touch 歸因模型的差異和選擇">Attribution&lt;/a>&lt;/li>
&lt;li>單次流程的流失分析 → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="使用者在哪一步流失 — 從事件序列計算每步轉換率、找出流失最嚴重的步驟、區分設計問題和技術問題">Funnel analysis&lt;/a>&lt;/li>
&lt;li>使用者分群的工程實作 → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/rfm-segmentation/" data-link-title="RFM 分群" data-link-desc="Recency / Frequency / Monetary 三維度的使用者分群 — 從行為事件計算 RFM 分數、定義使用者群體、驅動差異化策略">RFM 分群&lt;/a>&lt;/li>
&lt;li>客戶終身價值 → &lt;a href="https://tarrragon.github.io/blog/business/knowledge-cards/ltv/" data-link-title="LTV" data-link-desc="說明客戶終身價值與其在估值中的作用">LTV&lt;/a>&lt;/li>
&lt;li>留存率 → &lt;a href="https://tarrragon.github.io/blog/business/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明客戶留存率與其對單位經濟的決定作用">Retention&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p><a href="/blog/monitoring/knowledge-cards/cohort-analysis/" data-link-title="Cohort Analysis" data-link-desc="說明把使用者按共同特徵分群、比較不同群組行為差異的分析方法">Cohort analysis</a> 把使用者按共同特徵分群（cohort），比較不同群體在同一個指標上的表現差異。整體平均留存率 40% 可能隱藏了「1 月註冊的使用者留存 60%、3 月註冊的留存 20%」的差異。Cohort analysis 揭露平均值遮蔽的趨勢。</p>
<h2 id="cohort-的定義方式">Cohort 的定義方式</h2>
<h3 id="時間-cohort最常用">時間 cohort（最常用）</h3>
<p>按使用者完成某個動作的時間分群。「1 月份註冊的使用者」「第 12 週 onboarding 完成的使用者」。</p>
<p>時間 cohort 回答的問題：產品的留存率是否隨時間改善？新版本上線後註冊的使用者留存是否比舊版本高？</p>
<h3 id="行為-cohort">行為 cohort</h3>
<p>按使用者的行為特徵分群。「首次使用就完成購買的使用者」「使用過搜尋功能的使用者」「連續 3 天登入的使用者」。</p>
<p>行為 cohort 回答的問題：哪些行為和留存相關？做了 X 的使用者留存率是否比沒做 X 的高？</p>
<h3 id="屬性-cohort">屬性 cohort</h3>
<p>按使用者的固有屬性分群。「iOS 使用者」「企業方案使用者」「來自特定廣告渠道的使用者」。</p>
<p>屬性 cohort 回答的問題：不同平台/方案/來源的使用者行為是否不同？</p>
<h2 id="留存率矩陣">留存率矩陣</h2>
<p>留存率矩陣是 cohort analysis 最常見的呈現方式。每行代表一個 cohort（例如某月註冊的使用者），每列代表註冊後的第 N 天/週/月，格中的值是該 cohort 在第 N 期仍活躍的比例。</p>
<table>
  <thead>
      <tr>
          <th>Cohort</th>
          <th>第 0 週</th>
          <th>第 1 週</th>
          <th>第 2 週</th>
          <th>第 4 週</th>
          <th>第 8 週</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1 月</td>
          <td>100%</td>
          <td>45%</td>
          <td>32%</td>
          <td>22%</td>
          <td>18%</td>
      </tr>
      <tr>
          <td>2 月</td>
          <td>100%</td>
          <td>48%</td>
          <td>35%</td>
          <td>25%</td>
          <td>20%</td>
      </tr>
      <tr>
          <td>3 月</td>
          <td>100%</td>
          <td>52%</td>
          <td>40%</td>
          <td>30%</td>
          <td>—</td>
      </tr>
  </tbody>
</table>
<p>從這張矩陣可以看到：留存率逐月改善（1 月 → 3 月的第 1 週留存從 45% 升到 52%）。如果 2 月有產品改版，這個改善可能和改版相關。</p>
<h2 id="cohort-analysis-的判讀">Cohort analysis 的判讀</h2>
<h3 id="自然衰減-vs-產品問題">自然衰減 vs 產品問題</h3>
<p>所有產品都有自然衰減 — 使用者隨時間減少是正常的。Cohort analysis 的價值在於區分「正常衰減」和「異常衰減」。</p>
<p>如果所有 cohort 的衰減曲線形狀相似，衰減是產品層面的結構性問題（例如缺少持續使用的理由）。如果某個 cohort 的衰減明顯比其他 cohort 快，需要調查該 cohort 的特殊情況（當時的產品版本、市場環境、使用者來源）。</p>
<h3 id="穩態留存">穩態留存</h3>
<p>留存率通常在某個時間點後趨於穩定 — 留下來的使用者不再大量流失。穩態留存的百分比和到達穩態的時間是產品健康度的核心指標。</p>
<p>穩態留存高但到達時間長 = 產品有價值但 onboarding 需要改善。穩態留存低 = 產品的持續使用價值不足。</p>
<h2 id="和-funnel-的關係">和 funnel 的關係</h2>
<p>Funnel analysis 回答「使用者在哪一步流失」（單次流程），cohort analysis 回答「使用者是否持續回來」（長期行為）。兩者互補：funnel 改善單次流程的轉換率，cohort 追蹤改善是否帶來長期留存的變化。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>使用者從哪來 → <a href="/blog/monitoring/08-business-analytics/attribution/" data-link-title="Attribution" data-link-desc="使用者從哪來、哪個渠道帶來轉換 — last-touch / first-touch / multi-touch 歸因模型的差異和選擇">Attribution</a></li>
<li>單次流程的流失分析 → <a href="/blog/monitoring/08-business-analytics/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="使用者在哪一步流失 — 從事件序列計算每步轉換率、找出流失最嚴重的步驟、區分設計問題和技術問題">Funnel analysis</a></li>
<li>使用者分群的工程實作 → <a href="/blog/monitoring/08-business-analytics/rfm-segmentation/" data-link-title="RFM 分群" data-link-desc="Recency / Frequency / Monetary 三維度的使用者分群 — 從行為事件計算 RFM 分數、定義使用者群體、驅動差異化策略">RFM 分群</a></li>
<li>客戶終身價值 → <a href="/blog/business/knowledge-cards/ltv/" data-link-title="LTV" data-link-desc="說明客戶終身價值與其在估值中的作用">LTV</a></li>
<li>留存率 → <a href="/blog/business/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明客戶留存率與其對單位經濟的決定作用">Retention</a></li>
</ul>
]]></content:encoded></item><item><title>Cohort Analysis</title><link>https://tarrragon.github.io/blog/monitoring/knowledge-cards/cohort-analysis/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/knowledge-cards/cohort-analysis/</guid><description>&lt;p>Cohort analysis 的核心概念是「把使用者按共同特徵分群，比較不同群組的行為差異」。Cohort 通常按時間（註冊月份）、行為（首次使用的功能）、或屬性（付費方案）分群。可先對照 &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/knowledge-cards/rfm/" data-link-title="RFM" data-link-desc="說明用 Recency / Frequency / Monetary 三個維度把使用者分成可操作群組的分群方法">RFM&lt;/a>（按行為指標分群）。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Cohort analysis 位在 funnel analysis 之後、策略制定之前。Funnel analysis 回答「使用者在哪一步流失」，cohort analysis 回答「哪種使用者流失率高」。兩者搭配使用：funnel 找到流失步驟，cohort 找到流失群組，策略針對特定群組的流失步驟設計。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>產品需要 cohort analysis 的訊號是「整體留存率或轉換率的平均值遮蔽了群組差異」。整體 30 天留存率 40%，但按註冊來源拆分後發現自然搜尋來的使用者留存 60%、廣告來的使用者留存 20% — 平均值沒有揭露這個差異。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Cohort analysis 要定義分群維度（按什麼特徵分）、觀察指標（留存率、活躍度、付費率）、觀察時間窗口（7 天、30 天、90 天）、以及最小群組大小（群組太小時統計不顯著）。分群維度的選擇決定了分析能揭露什麼 — 按「註冊來源」分群能看到獲客通路的品質差異，按「使用的功能」分群能看到功能黏著度差異。&lt;/p></description><content:encoded><![CDATA[<p>Cohort analysis 的核心概念是「把使用者按共同特徵分群，比較不同群組的行為差異」。Cohort 通常按時間（註冊月份）、行為（首次使用的功能）、或屬性（付費方案）分群。可先對照 <a href="/blog/monitoring/knowledge-cards/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="說明追蹤使用者在多步驟流程中每一步的轉換率和流失率的分析方法">funnel analysis</a>（追蹤單一流程的每步轉換）和 <a href="/blog/monitoring/knowledge-cards/rfm/" data-link-title="RFM" data-link-desc="說明用 Recency / Frequency / Monetary 三個維度把使用者分成可操作群組的分群方法">RFM</a>（按行為指標分群）。</p>
<h2 id="概念位置">概念位置</h2>
<p>Cohort analysis 位在 funnel analysis 之後、策略制定之前。Funnel analysis 回答「使用者在哪一步流失」，cohort analysis 回答「哪種使用者流失率高」。兩者搭配使用：funnel 找到流失步驟，cohort 找到流失群組，策略針對特定群組的流失步驟設計。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>產品需要 cohort analysis 的訊號是「整體留存率或轉換率的平均值遮蔽了群組差異」。整體 30 天留存率 40%，但按註冊來源拆分後發現自然搜尋來的使用者留存 60%、廣告來的使用者留存 20% — 平均值沒有揭露這個差異。</p>
<h2 id="設計責任">設計責任</h2>
<p>Cohort analysis 要定義分群維度（按什麼特徵分）、觀察指標（留存率、活躍度、付費率）、觀察時間窗口（7 天、30 天、90 天）、以及最小群組大小（群組太小時統計不顯著）。分群維度的選擇決定了分析能揭露什麼 — 按「註冊來源」分群能看到獲客通路的品質差異，按「使用的功能」分群能看到功能黏著度差異。</p>
]]></content:encoded></item><item><title>Collector Access Control 實作</title><link>https://tarrragon.github.io/blog/monitoring/07-security-privacy/collector-access-control/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/07-security-privacy/collector-access-control/</guid><description>&lt;p>Collector access control 管理「誰可以對 collector 做什麼操作」。三層控制各自回答不同的問題：認證回答「來源是誰」，授權回答「這個來源被允許做什麼」，access log 回答「誰在什麼時候實際做了什麼」。&lt;/p>
&lt;h2 id="認證來源是誰">認證：來源是誰&lt;/h2>
&lt;p>認證驗證送出資料的 client 是否合法。未認證的 request 應該被拒絕，避免任意來源向 collector 寫入資料。&lt;/p>
&lt;h3 id="api-key-認證">API Key 認證&lt;/h3>
&lt;p>每個合法的 SDK client 有一個 API key。Collector 檢查 request header 中的 API key 是否在合法清單中。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">authMiddleware&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">next&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Handler&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Handler&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">HandlerFunc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">key&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Header&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;X-API-Key&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nf">isValidKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;unauthorized&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusUnauthorized&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">next&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ServeHTTP&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>自用工具場景下，一個 API key 對應一個 client 通常就足夠。多個 client（例如同一個 app 的 iOS 和 Android 版本）可以用同一個 key，或每個平台一個 key 以便在 access log 中區分來源。&lt;/p>
&lt;h3 id="mtlsmutual-tls">mTLS（Mutual TLS）&lt;/h3>
&lt;p>Client 和 server 互相驗證對方的憑證。安全性比 API key 高 — 攻擊者即使取得 API key，沒有 client 憑證也無法連線。&lt;/p>
&lt;p>mTLS 的設定成本較高（每個 client 需要產生和管理憑證），適合對安全性要求較高的環境。自用工具通常不需要 mTLS。&lt;/p>
&lt;h2 id="授權允許做什麼">授權：允許做什麼&lt;/h2>
&lt;p>授權控制已認證的 client 可以執行哪些操作。Collector 的操作通常分為兩類：寫入事件和查詢事件。&lt;/p>
&lt;h3 id="角色分離">角色分離&lt;/h3>
&lt;p>最簡單的授權模型是兩個角色：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Writer&lt;/strong>：只能寫入事件（POST /events）。SDK client 使用這個角色。&lt;/li>
&lt;li>&lt;strong>Reader&lt;/strong>：只能查詢事件（GET /events、GET /query）。開發者的 CLI 工具使用這個角色。&lt;/li>
&lt;/ul>
&lt;p>角色分離的價值在於限制洩漏的影響範圍。如果 SDK 的 API key 被洩漏，攻擊者只能寫入（產生垃圾事件），不能讀取（看到歷史事件中的敏感資訊）。&lt;/p>
&lt;h3 id="寫入限制">寫入限制&lt;/h3>
&lt;p>即使認證通過、角色正確，collector 也可以對寫入加上限制：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Rate limit&lt;/strong>：每個 API key 每分鐘最多 N 個 request。防止 client 端 bug 導致事件風暴。&lt;/li>
&lt;li>&lt;strong>Payload size limit&lt;/strong>：每個事件最大 M KB。防止異常大的 event data 消耗儲存。&lt;/li>
&lt;li>&lt;strong>Schema validation&lt;/strong>：事件必須符合定義的 JSON schema。格式不正確的事件拒絕存入。&lt;/li>
&lt;/ul>
&lt;h2 id="access-log誰做了什麼">Access Log：誰做了什麼&lt;/h2>
&lt;p>Access log 記錄每個到達 collector 的 request — 來源 IP、API key（或 key 的 hash）、操作類型、時間戳、response status。&lt;/p>
&lt;p>Access log 的用途：&lt;/p>
&lt;p>&lt;strong>安全審計&lt;/strong>：發現異常行為 — 未知 IP 的大量寫入、非工作時間的讀取、連續的認證失敗。&lt;/p>
&lt;p>&lt;strong>問題排查&lt;/strong>：SDK 說事件送出成功但 collector 沒有收到 — access log 可以確認 request 是否到達、response 是什麼。&lt;/p>
&lt;p>&lt;strong>用量統計&lt;/strong>：每個 client 送了多少事件、佔多少儲存。&lt;/p>
&lt;p>Access log 本身也是監控資料，但和業務事件分開儲存。Access log 存在 collector 本機的 log 檔中，用系統的 logrotate 管理輪替。&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">2026-06-19T10:30:00Z POST /events key=sk_mon_ab...cd ip=192.168.1.50 status=200 size=1234
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">2026-06-19T10:30:01Z POST /events key=INVALID ip=10.0.0.99 status=401 size=0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">2026-06-19T10:31:00Z GET /query key=sk_read_ef...gh ip=192.168.1.1 status=200 size=8901&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>SDK 端的 redaction → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/sdk-redaction-api/" data-link-title="SDK Redaction API 設計" data-link-desc="預設 redaction rule 過濾已知敏感欄位、自訂 pattern 擴展應用特有的 secret 格式 — redaction 在 SDK 端執行，敏感資料不離開 client">SDK Redaction API 設計&lt;/a>&lt;/li>
&lt;li>Transport 層的加密 → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全&lt;/a>&lt;/li>
&lt;li>資料儲存後的去識別化 → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/anonymization-strategy/" data-link-title="去識別化策略" data-link-desc="IP 截斷 / user agent 簡化 / stack trace 路徑清理 / session UUID — 四種去識別化技術的適用場景和實作方式">去識別化策略&lt;/a>&lt;/li>
&lt;li>Client-side credential 暴露的根本限制 → &lt;a href="https://tarrragon.github.io/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 認證&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Collector access control 管理「誰可以對 collector 做什麼操作」。三層控制各自回答不同的問題：認證回答「來源是誰」，授權回答「這個來源被允許做什麼」，access log 回答「誰在什麼時候實際做了什麼」。</p>
<h2 id="認證來源是誰">認證：來源是誰</h2>
<p>認證驗證送出資料的 client 是否合法。未認證的 request 應該被拒絕，避免任意來源向 collector 寫入資料。</p>
<h3 id="api-key-認證">API Key 認證</h3>
<p>每個合法的 SDK client 有一個 API key。Collector 檢查 request header 中的 API key 是否在合法清單中。</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">func</span> <span class="nf">authMiddleware</span><span class="p">(</span><span class="nx">next</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span><span class="p">)</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">return</span> <span class="nx">http</span><span class="p">.</span><span class="nf">HandlerFunc</span><span class="p">(</span><span class="kd">func</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">key</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;X-API-Key&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">if</span> <span class="p">!</span><span class="nf">isValidKey</span><span class="p">(</span><span class="nx">key</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;unauthorized&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusUnauthorized</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">next</span><span class="p">.</span><span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">r</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>自用工具場景下，一個 API key 對應一個 client 通常就足夠。多個 client（例如同一個 app 的 iOS 和 Android 版本）可以用同一個 key，或每個平台一個 key 以便在 access log 中區分來源。</p>
<h3 id="mtlsmutual-tls">mTLS（Mutual TLS）</h3>
<p>Client 和 server 互相驗證對方的憑證。安全性比 API key 高 — 攻擊者即使取得 API key，沒有 client 憑證也無法連線。</p>
<p>mTLS 的設定成本較高（每個 client 需要產生和管理憑證），適合對安全性要求較高的環境。自用工具通常不需要 mTLS。</p>
<h2 id="授權允許做什麼">授權：允許做什麼</h2>
<p>授權控制已認證的 client 可以執行哪些操作。Collector 的操作通常分為兩類：寫入事件和查詢事件。</p>
<h3 id="角色分離">角色分離</h3>
<p>最簡單的授權模型是兩個角色：</p>
<ul>
<li><strong>Writer</strong>：只能寫入事件（POST /events）。SDK client 使用這個角色。</li>
<li><strong>Reader</strong>：只能查詢事件（GET /events、GET /query）。開發者的 CLI 工具使用這個角色。</li>
</ul>
<p>角色分離的價值在於限制洩漏的影響範圍。如果 SDK 的 API key 被洩漏，攻擊者只能寫入（產生垃圾事件），不能讀取（看到歷史事件中的敏感資訊）。</p>
<h3 id="寫入限制">寫入限制</h3>
<p>即使認證通過、角色正確，collector 也可以對寫入加上限制：</p>
<ul>
<li><strong>Rate limit</strong>：每個 API key 每分鐘最多 N 個 request。防止 client 端 bug 導致事件風暴。</li>
<li><strong>Payload size limit</strong>：每個事件最大 M KB。防止異常大的 event data 消耗儲存。</li>
<li><strong>Schema validation</strong>：事件必須符合定義的 JSON schema。格式不正確的事件拒絕存入。</li>
</ul>
<h2 id="access-log誰做了什麼">Access Log：誰做了什麼</h2>
<p>Access log 記錄每個到達 collector 的 request — 來源 IP、API key（或 key 的 hash）、操作類型、時間戳、response status。</p>
<p>Access log 的用途：</p>
<p><strong>安全審計</strong>：發現異常行為 — 未知 IP 的大量寫入、非工作時間的讀取、連續的認證失敗。</p>
<p><strong>問題排查</strong>：SDK 說事件送出成功但 collector 沒有收到 — access log 可以確認 request 是否到達、response 是什麼。</p>
<p><strong>用量統計</strong>：每個 client 送了多少事件、佔多少儲存。</p>
<p>Access log 本身也是監控資料，但和業務事件分開儲存。Access log 存在 collector 本機的 log 檔中，用系統的 logrotate 管理輪替。</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">2026-06-19T10:30:00Z POST /events key=sk_mon_ab...cd ip=192.168.1.50 status=200 size=1234
</span></span><span class="line"><span class="ln">2</span><span class="cl">2026-06-19T10:30:01Z POST /events key=INVALID ip=10.0.0.99 status=401 size=0
</span></span><span class="line"><span class="ln">3</span><span class="cl">2026-06-19T10:31:00Z GET /query key=sk_read_ef...gh ip=192.168.1.1 status=200 size=8901</span></span></code></pre></div><h2 id="下一步路由">下一步路由</h2>
<ul>
<li>SDK 端的 redaction → <a href="/blog/monitoring/07-security-privacy/sdk-redaction-api/" data-link-title="SDK Redaction API 設計" data-link-desc="預設 redaction rule 過濾已知敏感欄位、自訂 pattern 擴展應用特有的 secret 格式 — redaction 在 SDK 端執行，敏感資料不離開 client">SDK Redaction API 設計</a></li>
<li>Transport 層的加密 → <a href="/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全</a></li>
<li>資料儲存後的去識別化 → <a href="/blog/monitoring/07-security-privacy/anonymization-strategy/" data-link-title="去識別化策略" data-link-desc="IP 截斷 / user agent 簡化 / stack trace 路徑清理 / session UUID — 四種去識別化技術的適用場景和實作方式">去識別化策略</a></li>
<li>Client-side credential 暴露的根本限制 → <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>
</ul>
]]></content:encoded></item><item><title>Firebase 套件</title><link>https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/firebase-suite/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/firebase-suite/</guid><description>&lt;p>Firebase 把 client-side 監控拆成多個獨立產品：Crashlytics 負責 crash 報告、Analytics（GA4）負責行為分析、Remote Config 負責功能旗標和 A/B test。三個產品各自有 SDK、dashboard 和計費模型，但共享 Firebase project 的使用者識別。&lt;/p>
&lt;h2 id="crashlytics">Crashlytics&lt;/h2>
&lt;p>Firebase Crashlytics 專注在 crash 報告 — fatal crash（app 當機）和 non-fatal exception（被捕獲但值得記錄的錯誤）。&lt;/p>
&lt;h3 id="自動-crash-報告">自動 crash 報告&lt;/h3>
&lt;p>Crashlytics SDK 在 app crash 時自動收集 crash 資訊（stack trace、device info、OS version），在下次 app 啟動時上傳。不需要開發者寫程式碼 — SDK 初始化後自動運作。&lt;/p>
&lt;h3 id="issue-分群">Issue 分群&lt;/h3>
&lt;p>和 Sentry 類似，Crashlytics 用 stack trace 自動把 crash 分群成 issue。每個 issue 有影響的使用者數、趨勢、crash-free session 比率。&lt;/p>
&lt;h3 id="和-analytics-的關聯">和 Analytics 的關聯&lt;/h3>
&lt;p>Crashlytics 可以在 crash 報告中附加 Analytics 的使用者屬性和自訂 key。但兩者的 dashboard 獨立 — crash 資料在 Crashlytics console，行為資料在 Analytics console。要從「crash」追蹤到「crash 前使用者做了什麼」需要在兩個 console 之間切換。&lt;/p>
&lt;h2 id="analyticsga4">Analytics（GA4）&lt;/h2>
&lt;p>Firebase Analytics 是 Google Analytics 4（GA4）的 mobile SDK 版本。記錄使用者操作事件（screen view、button click、purchase）和使用者屬性。&lt;/p>
&lt;h3 id="自動收集事件">自動收集事件&lt;/h3>
&lt;p>GA4 SDK 自動收集一組預定義事件：&lt;code>first_open&lt;/code>、&lt;code>session_start&lt;/code>、&lt;code>screen_view&lt;/code>、&lt;code>user_engagement&lt;/code>。開發者不需要手動埋點就能得到基礎的使用統計。&lt;/p>
&lt;h3 id="自訂事件">自訂事件&lt;/h3>
&lt;p>開發者用 &lt;code>logEvent(name, parameters)&lt;/code> 記錄自訂事件。事件名稱和參數的命名有限制（名稱 40 字元、參數 25 個、參數值 100 字元）。&lt;/p>
&lt;h3 id="和四類事件的對應">和四類事件的對應&lt;/h3>
&lt;p>GA4 主要處理 Event 類和 Lifecycle 類事件（&lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/four-event-types/" data-link-title="四類事件的完整定義" data-link-desc="Event / Error / Metric / Lifecycle 四類事件各自的語意、觸發時機和典型用途 — 分類是監控體系的統一語言">模組一&lt;/a>）。Error 類由 Crashlytics 處理。Metric 類沒有原生支援 — 需要把 metric 包裝成 event 的 parameter。&lt;/p>
&lt;h2 id="remote-config">Remote Config&lt;/h2>
&lt;p>Firebase Remote Config 讓開發者在不更新 app 的情況下修改 app 的行為 — 功能旗標（feature flag）、UI 文案、數值參數。&lt;/p>
&lt;h3 id="和-ab-test-的整合">和 A/B test 的整合&lt;/h3>
&lt;p>Remote Config 和 Firebase A/B Testing 整合：定義實驗（variant A: 舊 UI / variant B: 新 UI），Remote Config 自動分配使用者到 variant，Analytics 收集兩組使用者的行為數據，A/B Testing console 顯示統計結果。&lt;/p>
&lt;p>這個整合是 Firebase 生態的獨特優勢 — config 分發、使用者分群、行為收集、統計分析在同一個平台完成，不需要整合多個工具。&lt;/p>
&lt;h2 id="firebase-的取捨">Firebase 的取捨&lt;/h2>
&lt;p>Firebase 的設計取捨是「拆分但整合」— 每個產品獨立運作（可以只用 Crashlytics 不用 Analytics），但組合使用時有整合優勢（Crashlytics + Analytics 的 user ID 共享）。&lt;/p></description><content:encoded><![CDATA[<p>Firebase 把 client-side 監控拆成多個獨立產品：Crashlytics 負責 crash 報告、Analytics（GA4）負責行為分析、Remote Config 負責功能旗標和 A/B test。三個產品各自有 SDK、dashboard 和計費模型，但共享 Firebase project 的使用者識別。</p>
<h2 id="crashlytics">Crashlytics</h2>
<p>Firebase Crashlytics 專注在 crash 報告 — fatal crash（app 當機）和 non-fatal exception（被捕獲但值得記錄的錯誤）。</p>
<h3 id="自動-crash-報告">自動 crash 報告</h3>
<p>Crashlytics SDK 在 app crash 時自動收集 crash 資訊（stack trace、device info、OS version），在下次 app 啟動時上傳。不需要開發者寫程式碼 — SDK 初始化後自動運作。</p>
<h3 id="issue-分群">Issue 分群</h3>
<p>和 Sentry 類似，Crashlytics 用 stack trace 自動把 crash 分群成 issue。每個 issue 有影響的使用者數、趨勢、crash-free session 比率。</p>
<h3 id="和-analytics-的關聯">和 Analytics 的關聯</h3>
<p>Crashlytics 可以在 crash 報告中附加 Analytics 的使用者屬性和自訂 key。但兩者的 dashboard 獨立 — crash 資料在 Crashlytics console，行為資料在 Analytics console。要從「crash」追蹤到「crash 前使用者做了什麼」需要在兩個 console 之間切換。</p>
<h2 id="analyticsga4">Analytics（GA4）</h2>
<p>Firebase Analytics 是 Google Analytics 4（GA4）的 mobile SDK 版本。記錄使用者操作事件（screen view、button click、purchase）和使用者屬性。</p>
<h3 id="自動收集事件">自動收集事件</h3>
<p>GA4 SDK 自動收集一組預定義事件：<code>first_open</code>、<code>session_start</code>、<code>screen_view</code>、<code>user_engagement</code>。開發者不需要手動埋點就能得到基礎的使用統計。</p>
<h3 id="自訂事件">自訂事件</h3>
<p>開發者用 <code>logEvent(name, parameters)</code> 記錄自訂事件。事件名稱和參數的命名有限制（名稱 40 字元、參數 25 個、參數值 100 字元）。</p>
<h3 id="和四類事件的對應">和四類事件的對應</h3>
<p>GA4 主要處理 Event 類和 Lifecycle 類事件（<a href="/blog/monitoring/01-mental-model/four-event-types/" data-link-title="四類事件的完整定義" data-link-desc="Event / Error / Metric / Lifecycle 四類事件各自的語意、觸發時機和典型用途 — 分類是監控體系的統一語言">模組一</a>）。Error 類由 Crashlytics 處理。Metric 類沒有原生支援 — 需要把 metric 包裝成 event 的 parameter。</p>
<h2 id="remote-config">Remote Config</h2>
<p>Firebase Remote Config 讓開發者在不更新 app 的情況下修改 app 的行為 — 功能旗標（feature flag）、UI 文案、數值參數。</p>
<h3 id="和-ab-test-的整合">和 A/B test 的整合</h3>
<p>Remote Config 和 Firebase A/B Testing 整合：定義實驗（variant A: 舊 UI / variant B: 新 UI），Remote Config 自動分配使用者到 variant，Analytics 收集兩組使用者的行為數據，A/B Testing console 顯示統計結果。</p>
<p>這個整合是 Firebase 生態的獨特優勢 — config 分發、使用者分群、行為收集、統計分析在同一個平台完成，不需要整合多個工具。</p>
<h2 id="firebase-的取捨">Firebase 的取捨</h2>
<p>Firebase 的設計取捨是「拆分但整合」— 每個產品獨立運作（可以只用 Crashlytics 不用 Analytics），但組合使用時有整合優勢（Crashlytics + Analytics 的 user ID 共享）。</p>
<table>
  <thead>
      <tr>
          <th>優勢</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自動收集、零配置啟動</td>
          <td>自訂彈性受限（事件命名限制、參數數量限制）</td>
      </tr>
      <tr>
          <td>Crashlytics 免費且無量限制</td>
          <td>Analytics 的進階功能需要 BigQuery export（另收費）</td>
      </tr>
      <tr>
          <td>A/B test 整合開箱即用</td>
          <td>鎖定 Google 生態（資料 export 有限制）</td>
      </tr>
      <tr>
          <td>Mobile 優先，Flutter 支援佳</td>
          <td>Web 的支援較弱（GA4 web 是獨立產品線）</td>
      </tr>
  </tbody>
</table>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Datadog 的全棧 APM → <a href="/blog/monitoring/06-commercial-comparison/datadog-rum/" data-link-title="Datadog RUM" data-link-desc="全棧 APM 的 client-side 觀點 — client action 到 server trace 的完整鏈路追蹤">Datadog RUM</a></li>
<li>行為分析專用方案 → <a href="/blog/monitoring/06-commercial-comparison/mixpanel-amplitude/" data-link-title="Mixpanel / Amplitude" data-link-desc="行為分析專用方案 vs 通用監控的差異 — Mixpanel 和 Amplitude 的 funnel / cohort / retention 分析能力">Mixpanel / Amplitude</a></li>
<li>自架 vs 商業的判斷 → <a href="/blog/monitoring/06-commercial-comparison/self-hosted-vs-commercial/" data-link-title="自架 vs 商業的判斷決策表" data-link-desc="使用者數、網路範圍、功能需求、合規要求四個維度判斷該自架還是用商業方案">自架 vs 商業的判斷決策表</a></li>
</ul>
]]></content:encoded></item><item><title>Python 平台適配</title><link>https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/python-platform/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/python-platform/</guid><description>&lt;p>Python 的執行模型（GIL 限制並行、atexit 不保證執行、subprocess 獨立 process）讓監控 SDK 在 Python 環境中需要特別處理 flush 的執行方式、程序退出時的事件保存和子程序的監控。&lt;/p>
&lt;h2 id="gil-與-threading">GIL 與 threading&lt;/h2>
&lt;p>Python 的 Global Interpreter Lock（GIL）讓同一時間只有一個 thread 執行 Python bytecode。SDK 的 flush 操作（HTTP POST 到 collector）如果在主 thread 執行，會阻塞主程式的其他工作。&lt;/p>
&lt;p>SDK 端的適配：&lt;/p>
&lt;p>在 daemon thread 中執行 flush。Daemon thread 在主 thread 結束時自動終止，不需要手動 join。SDK 的 flush 計時器在 daemon thread 中運行，buffer 的存取用 threading.Lock 保護。&lt;/p>
&lt;p>GIL 對 SDK 的影響比想像的小：HTTP 請求是 I/O bound 操作，CPython 在等待 I/O 時釋放 GIL。所以 flush 的 HTTP POST 在 daemon thread 中執行時，主 thread 可以繼續工作。GIL 只在 CPU-bound 的操作上造成瓶頸 — SDK 的 buffer 操作和事件序列化是 CPU-bound 但耗時極短（微秒級），影響可忽略。&lt;/p>
&lt;h3 id="asyncio-環境">asyncio 環境&lt;/h3>
&lt;p>Python 的 asyncio 程式（FastAPI、aiohttp）使用事件迴圈而非 threading。SDK 在 asyncio 環境中應該用 &lt;code>asyncio.create_task&lt;/code> 而非 threading 執行 flush，避免在事件迴圈中阻塞。&lt;/p>
&lt;p>SDK 可以在 init 時自動偵測是否在 asyncio 環境中（檢查 &lt;code>asyncio.get_running_loop()&lt;/code> 是否存在），自動切換 flush 的執行方式。&lt;/p>
&lt;h2 id="atexit-可靠性">atexit 可靠性&lt;/h2>
&lt;p>&lt;code>atexit.register&lt;/code> 在 Python 程序正常退出時執行註冊的清理函式。SDK 在 init 時註冊 atexit handler 做最後一次 flush。&lt;/p>
&lt;p>atexit 不執行的場景：&lt;/p>
&lt;ul>
&lt;li>&lt;code>os._exit()&lt;/code> 直接終止 process，跳過所有清理&lt;/li>
&lt;li>SIGKILL（&lt;code>kill -9&lt;/code>）強制終止，作業系統直接回收 process&lt;/li>
&lt;li>未處理的 fatal signal（SIGSEGV、SIGABRT）導致 crash&lt;/li>
&lt;/ul>
&lt;p>對於 SIGTERM 和 SIGINT，Python 預設會執行 atexit handler（前提是 signal handler 沒有被覆蓋）。SDK 可以額外註冊 &lt;code>signal.signal(signal.SIGTERM, handler)&lt;/code> 確保在收到 SIGTERM 時觸發 flush。&lt;/p>
&lt;p>實務影響：&lt;code>os._exit()&lt;/code> 和 SIGKILL 導致的事件遺失無法避免。使用本地 persistence（&lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">離線 buffer&lt;/a>）可以降低影響 — 事件在寫入本地檔案後，即使 process 被強制終止，下次啟動時仍可補發。&lt;/p>
&lt;h2 id="短生命週期腳本">短生命週期腳本&lt;/h2>
&lt;p>SDK 的預設設計假設長期運行的 app — flush interval 定期觸發、daemon thread 持續運行、atexit 是最後防線。但 Python SDK 的一個重要場景是短命腳本（CI/CD hook、pre-commit hook、CLI 工具的子命令），生命週期可能 &amp;lt; 1 秒。這個場景下 SDK 的行為和長期 app 完全不同。&lt;/p>
&lt;h3 id="什麼會壞">什麼會壞&lt;/h3>
&lt;p>&lt;strong>flush interval 來不及觸發&lt;/strong>。預設 30 秒的 flush interval，但腳本在 200ms 內結束。計時器還沒觸發，buffer 中的事件從未送出。&lt;/p></description><content:encoded><![CDATA[<p>Python 的執行模型（GIL 限制並行、atexit 不保證執行、subprocess 獨立 process）讓監控 SDK 在 Python 環境中需要特別處理 flush 的執行方式、程序退出時的事件保存和子程序的監控。</p>
<h2 id="gil-與-threading">GIL 與 threading</h2>
<p>Python 的 Global Interpreter Lock（GIL）讓同一時間只有一個 thread 執行 Python bytecode。SDK 的 flush 操作（HTTP POST 到 collector）如果在主 thread 執行，會阻塞主程式的其他工作。</p>
<p>SDK 端的適配：</p>
<p>在 daemon thread 中執行 flush。Daemon thread 在主 thread 結束時自動終止，不需要手動 join。SDK 的 flush 計時器在 daemon thread 中運行，buffer 的存取用 threading.Lock 保護。</p>
<p>GIL 對 SDK 的影響比想像的小：HTTP 請求是 I/O bound 操作，CPython 在等待 I/O 時釋放 GIL。所以 flush 的 HTTP POST 在 daemon thread 中執行時，主 thread 可以繼續工作。GIL 只在 CPU-bound 的操作上造成瓶頸 — SDK 的 buffer 操作和事件序列化是 CPU-bound 但耗時極短（微秒級），影響可忽略。</p>
<h3 id="asyncio-環境">asyncio 環境</h3>
<p>Python 的 asyncio 程式（FastAPI、aiohttp）使用事件迴圈而非 threading。SDK 在 asyncio 環境中應該用 <code>asyncio.create_task</code> 而非 threading 執行 flush，避免在事件迴圈中阻塞。</p>
<p>SDK 可以在 init 時自動偵測是否在 asyncio 環境中（檢查 <code>asyncio.get_running_loop()</code> 是否存在），自動切換 flush 的執行方式。</p>
<h2 id="atexit-可靠性">atexit 可靠性</h2>
<p><code>atexit.register</code> 在 Python 程序正常退出時執行註冊的清理函式。SDK 在 init 時註冊 atexit handler 做最後一次 flush。</p>
<p>atexit 不執行的場景：</p>
<ul>
<li><code>os._exit()</code> 直接終止 process，跳過所有清理</li>
<li>SIGKILL（<code>kill -9</code>）強制終止，作業系統直接回收 process</li>
<li>未處理的 fatal signal（SIGSEGV、SIGABRT）導致 crash</li>
</ul>
<p>對於 SIGTERM 和 SIGINT，Python 預設會執行 atexit handler（前提是 signal handler 沒有被覆蓋）。SDK 可以額外註冊 <code>signal.signal(signal.SIGTERM, handler)</code> 確保在收到 SIGTERM 時觸發 flush。</p>
<p>實務影響：<code>os._exit()</code> 和 SIGKILL 導致的事件遺失無法避免。使用本地 persistence（<a href="/blog/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">離線 buffer</a>）可以降低影響 — 事件在寫入本地檔案後，即使 process 被強制終止，下次啟動時仍可補發。</p>
<h2 id="短生命週期腳本">短生命週期腳本</h2>
<p>SDK 的預設設計假設長期運行的 app — flush interval 定期觸發、daemon thread 持續運行、atexit 是最後防線。但 Python SDK 的一個重要場景是短命腳本（CI/CD hook、pre-commit hook、CLI 工具的子命令），生命週期可能 &lt; 1 秒。這個場景下 SDK 的行為和長期 app 完全不同。</p>
<h3 id="什麼會壞">什麼會壞</h3>
<p><strong>flush interval 來不及觸發</strong>。預設 30 秒的 flush interval，但腳本在 200ms 內結束。計時器還沒觸發，buffer 中的事件從未送出。</p>
<p><strong>daemon thread 隨主 thread 結束</strong>。SDK 用 daemon thread 執行 flush 計時器。Python 的 daemon thread 在最後一個非 daemon thread 結束時被殺 — 不會等待 daemon thread 完成當前工作。如果 flush 正在進行中（HTTP POST 送到一半），daemon thread 被殺，HTTP 請求中斷，事件丟失。</p>
<p><strong>atexit 的執行順序不確定</strong>。atexit handler 在 daemon thread 被殺之後執行。如果 SDK 的 atexit handler 嘗試在 daemon thread 中 flush，會失敗（thread 已死）。atexit handler 必須在主 thread 中同步 flush。</p>
<h3 id="正確的短命腳本模式">正確的短命腳本模式</h3>





<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="kn">from</span> <span class="nn">monitor</span> <span class="kn">import</span> <span class="n">Monitor</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">Monitor</span><span class="o">.</span><span class="n">init</span><span class="p">(</span><span class="n">endpoint</span><span class="o">=</span><span class="s2">&#34;http://localhost:9090/v1/events&#34;</span><span class="p">,</span> <span class="n">app</span><span class="o">=</span><span class="s2">&#34;my-hook&#34;</span><span class="p">)</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"># 做事...</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">Monitor</span><span class="o">.</span><span class="n">event</span><span class="p">(</span><span class="s2">&#34;hook.run&#34;</span><span class="p">,</span> <span class="p">{</span><span class="s2">&#34;hook&#34;</span><span class="p">:</span> <span class="s2">&#34;branch-check&#34;</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"># 結束前必須呼叫 close</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="n">Monitor</span><span class="o">.</span><span class="n">close</span><span class="p">()</span>  <span class="c1"># close 內同步 flush，不依賴 daemon thread</span></span></span></code></pre></div><p><code>close()</code> 是唯一可靠的 flush 時機。<code>close()</code> 的實作在短命腳本場景下必須：</p>
<ol>
<li><strong>同步執行 HTTP POST</strong>，不委託給 daemon thread — 主 thread 呼叫 <code>close()</code> 時直接在當前 thread 送出</li>
<li><strong>設 HTTP timeout</strong> — 短命腳本不能等太久，3 秒的 timeout 是合理的</li>
<li><strong>flush 失敗時靜默放棄</strong> — 短命腳本的主要職責不是監控，SDK 失敗不應影響腳本的 exit code</li>
</ol>
<p><code>atexit</code> 仍然註冊，作為開發者忘記呼叫 <code>close()</code> 的備份。但 atexit 是 best-effort — 在 <code>os._exit()</code> 和 SIGKILL 下不執行。</p>
<h3 id="flush-interval-在短命腳本中的角色">flush interval 在短命腳本中的角色</h3>
<p>flush interval 對短命腳本無意義 — 腳本在第一次 interval 觸發前就結束了。SDK 可以偵測「init 到 close 的間隔 &lt; flush interval」的模式，在 debug log 中提示開發者考慮降低 interval 或直接依賴 <code>close()</code> flush。</p>
<p>但不建議把 flush interval 設為 0（停用）— 同一個 SDK 設定可能同時用於長期 app 和短命腳本，interval 對長期 app 仍然有用。</p>
<h2 id="subprocess-監控">Subprocess 監控</h2>
<p>Python 程式中的 <code>subprocess.Popen</code> 啟動的子程序是獨立的 process，不共享 SDK 的 buffer 和網路連線。子程序的錯誤和事件需要獨立的監控機制。</p>
<p>兩種方式：</p>
<p><strong>子程序獨立初始化 SDK</strong>：子程序的 Python 腳本自己呼叫 <code>Monitor.init()</code>，獨立送事件到 collector。適合子程序是長時間運行的 Python 程式。</p>
<p><strong>父程序代理</strong>：父程序讀取子程序的 stdout/stderr，從輸出中解析事件（子程序用約定格式印出事件），父程序的 SDK 代理送出。適合子程序是短命的腳本或非 Python 程式。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Go 平台的適配 → <a href="/blog/monitoring/05-platform-adaptation/go-platform/" data-link-title="Go 平台適配" data-link-desc="Graceful shutdown、signal handling、HTTP server 自身監控 — Go SDK 和 collector 端共同面對的平台問題">Go 平台適配</a></li>
<li>跨平台 timestamp 一致性 → <a href="/blog/monitoring/05-platform-adaptation/cross-platform-timestamp/" data-link-title="跨平台 timestamp 一致性" data-link-desc="時區、精度、clock drift — 不同平台產生的 timestamp 在 collector 端需要能正確比對和排序">跨平台 timestamp 一致性</a></li>
<li>離線 buffer 策略 → <a href="/blog/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">模組三 離線 buffer 與重試</a></li>
</ul>
]]></content:encoded></item><item><title>Schema 版本演進策略</title><link>https://tarrragon.github.io/blog/monitoring/02-log-schema/schema-versioning/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/02-log-schema/schema-versioning/</guid><description>&lt;p>Schema 版本演進的目標是讓新版 SDK 和舊版 SDK 產生的事件能被同一個 collector 正確處理。核心策略是 backward compatible 的增量變更 — 儘量用「新增選填欄位」代替「修改現有欄位」。&lt;/p>
&lt;h2 id="不需要改版的變更">不需要改版的變更&lt;/h2>
&lt;h3 id="新增選填欄位">新增選填欄位&lt;/h3>
&lt;p>在 data 區域新增欄位。舊版 SDK 送來的事件不包含這個欄位，collector 和查詢工具用「欄位不存在則忽略」的邏輯處理。&lt;/p>
&lt;p>例：v=1 的事件沒有 &lt;code>data.duration_ms&lt;/code>，v=1 的 SDK 升級後開始送 &lt;code>data.duration_ms&lt;/code>。Collector 不需要改 — 新欄位出現在 data 自由區域，不影響 schema 驗證。查詢時用 optional access。&lt;/p>
&lt;h3 id="新增事件名稱">新增事件名稱&lt;/h3>
&lt;p>新功能加入新的事件名稱（&lt;code>enrollment.qr.scan&lt;/code>）。事件名稱不受 schema 版本控制 — schema 定義的是事件的結構，不是事件名稱的清單。&lt;/p>
&lt;h2 id="需要改版的變更">需要改版的變更&lt;/h2>
&lt;h3 id="新增核心必填欄位">新增核心必填欄位&lt;/h3>
&lt;p>在核心區域（type、name、timestamp、source 同層）新增必填欄位。舊版 SDK 不會送這個欄位，collector 需要根據版本號決定是否要求這個欄位。&lt;/p>
&lt;p>例：v=2 新增必填的 &lt;code>environment&lt;/code> 欄位（production / staging / development）。v=1 的事件沒有這個欄位，collector 對 v=1 不要求 environment，對 v=2 要求 environment。&lt;/p>
&lt;h3 id="改變欄位型別">改變欄位型別&lt;/h3>
&lt;p>把 &lt;code>duration&lt;/code> 從 string（&lt;code>&amp;quot;320ms&amp;quot;&lt;/code>）改成 integer（&lt;code>320&lt;/code>）。同一個欄位的兩種型別需要不同的解析邏輯，collector 用版本號區分。&lt;/p>
&lt;h3 id="刪除或重新命名欄位">刪除或重新命名欄位&lt;/h3>
&lt;p>刪除欄位或改名（&lt;code>error_msg&lt;/code> → &lt;code>error_message&lt;/code>）需要改版。Collector 對舊版本讀舊欄位名，對新版本讀新欄位名。&lt;/p>
&lt;h2 id="collector-的多版本支援">Collector 的多版本支援&lt;/h2>
&lt;p>Collector 同時接收不同版本的事件。處理策略：&lt;/p>
&lt;h3 id="版本分派">版本分派&lt;/h3>
&lt;p>收到事件後先讀 v 欄位，分派到對應版本的處理器。每個版本的處理器知道該版本的欄位結構和驗證規則。&lt;/p>
&lt;h3 id="正規化">正規化&lt;/h3>
&lt;p>不同版本的事件正規化成統一的內部格式後儲存。正規化層處理欄位名稱對應（&lt;code>error_msg&lt;/code> → &lt;code>error_message&lt;/code>）和型別轉換（string → integer）。查詢時只面對正規化後的格式。&lt;/p>
&lt;h3 id="版本淘汰">版本淘汰&lt;/h3>
&lt;p>當所有 SDK 都升級到 v=2 後（從事件記錄中確認不再收到 v=1），可以移除 v=1 的處理器。淘汰前確認沒有離線 buffer 中的 v=1 事件尚未送達。&lt;/p>
&lt;h2 id="實務建議">實務建議&lt;/h2>
&lt;p>&lt;strong>遲改版優於早改版&lt;/strong>。每次改版增加 collector 的複雜度（多一個版本的處理器）。如果變更可以用「新增選填欄位」解決，優先選擇不改版。&lt;/p>
&lt;p>&lt;strong>一次改版包含多個變更&lt;/strong>。如果確定要改版，把多個計畫中的 breaking change 合併到同一次版本升級。v=1 → v=2 包含三個 breaking change，比 v=1 → v=2 → v=3 → v=4 各包含一個 breaking change 的維護成本低。&lt;/p>
&lt;p>&lt;strong>Schema 文件和版本號同步&lt;/strong>。每個版本的 schema 有對應的文件，記錄該版本和前一版本的差異。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>完整欄位定義 → &lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">event.schema.json 完整欄位解說&lt;/a>&lt;/li>
&lt;li>欄位設計原則 → &lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/field-design-principles/" data-link-title="欄位設計原則" data-link-desc="source 標明來源、data 自由欄位、v 版本演進 — 三個設計原則讓 schema 在不同階段都能使用">欄位設計原則&lt;/a>&lt;/li>
&lt;li>和 OpenTelemetry 的比較 → &lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/otel-comparison/" data-link-title="跟 OpenTelemetry 的 schema 差異對照" data-link-desc="自架 event schema 和 OTLP 的設計差異 — 為什麼 client-side 監控用簡化 schema、什麼時候切換到 OTLP">跟 OpenTelemetry 的 schema 差異對照&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Schema 版本演進的目標是讓新版 SDK 和舊版 SDK 產生的事件能被同一個 collector 正確處理。核心策略是 backward compatible 的增量變更 — 儘量用「新增選填欄位」代替「修改現有欄位」。</p>
<h2 id="不需要改版的變更">不需要改版的變更</h2>
<h3 id="新增選填欄位">新增選填欄位</h3>
<p>在 data 區域新增欄位。舊版 SDK 送來的事件不包含這個欄位，collector 和查詢工具用「欄位不存在則忽略」的邏輯處理。</p>
<p>例：v=1 的事件沒有 <code>data.duration_ms</code>，v=1 的 SDK 升級後開始送 <code>data.duration_ms</code>。Collector 不需要改 — 新欄位出現在 data 自由區域，不影響 schema 驗證。查詢時用 optional access。</p>
<h3 id="新增事件名稱">新增事件名稱</h3>
<p>新功能加入新的事件名稱（<code>enrollment.qr.scan</code>）。事件名稱不受 schema 版本控制 — schema 定義的是事件的結構，不是事件名稱的清單。</p>
<h2 id="需要改版的變更">需要改版的變更</h2>
<h3 id="新增核心必填欄位">新增核心必填欄位</h3>
<p>在核心區域（type、name、timestamp、source 同層）新增必填欄位。舊版 SDK 不會送這個欄位，collector 需要根據版本號決定是否要求這個欄位。</p>
<p>例：v=2 新增必填的 <code>environment</code> 欄位（production / staging / development）。v=1 的事件沒有這個欄位，collector 對 v=1 不要求 environment，對 v=2 要求 environment。</p>
<h3 id="改變欄位型別">改變欄位型別</h3>
<p>把 <code>duration</code> 從 string（<code>&quot;320ms&quot;</code>）改成 integer（<code>320</code>）。同一個欄位的兩種型別需要不同的解析邏輯，collector 用版本號區分。</p>
<h3 id="刪除或重新命名欄位">刪除或重新命名欄位</h3>
<p>刪除欄位或改名（<code>error_msg</code> → <code>error_message</code>）需要改版。Collector 對舊版本讀舊欄位名，對新版本讀新欄位名。</p>
<h2 id="collector-的多版本支援">Collector 的多版本支援</h2>
<p>Collector 同時接收不同版本的事件。處理策略：</p>
<h3 id="版本分派">版本分派</h3>
<p>收到事件後先讀 v 欄位，分派到對應版本的處理器。每個版本的處理器知道該版本的欄位結構和驗證規則。</p>
<h3 id="正規化">正規化</h3>
<p>不同版本的事件正規化成統一的內部格式後儲存。正規化層處理欄位名稱對應（<code>error_msg</code> → <code>error_message</code>）和型別轉換（string → integer）。查詢時只面對正規化後的格式。</p>
<h3 id="版本淘汰">版本淘汰</h3>
<p>當所有 SDK 都升級到 v=2 後（從事件記錄中確認不再收到 v=1），可以移除 v=1 的處理器。淘汰前確認沒有離線 buffer 中的 v=1 事件尚未送達。</p>
<h2 id="實務建議">實務建議</h2>
<p><strong>遲改版優於早改版</strong>。每次改版增加 collector 的複雜度（多一個版本的處理器）。如果變更可以用「新增選填欄位」解決，優先選擇不改版。</p>
<p><strong>一次改版包含多個變更</strong>。如果確定要改版，把多個計畫中的 breaking change 合併到同一次版本升級。v=1 → v=2 包含三個 breaking change，比 v=1 → v=2 → v=3 → v=4 各包含一個 breaking change 的維護成本低。</p>
<p><strong>Schema 文件和版本號同步</strong>。每個版本的 schema 有對應的文件，記錄該版本和前一版本的差異。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>完整欄位定義 → <a href="/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">event.schema.json 完整欄位解說</a></li>
<li>欄位設計原則 → <a href="/blog/monitoring/02-log-schema/field-design-principles/" data-link-title="欄位設計原則" data-link-desc="source 標明來源、data 自由欄位、v 版本演進 — 三個設計原則讓 schema 在不同階段都能使用">欄位設計原則</a></li>
<li>和 OpenTelemetry 的比較 → <a href="/blog/monitoring/02-log-schema/otel-comparison/" data-link-title="跟 OpenTelemetry 的 schema 差異對照" data-link-desc="自架 event schema 和 OTLP 的設計差異 — 為什麼 client-side 監控用簡化 schema、什麼時候切換到 OTLP">跟 OpenTelemetry 的 schema 差異對照</a></li>
</ul>
]]></content:encoded></item><item><title>查詢 API 設計</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/query-api/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/query-api/</guid><description>&lt;p>查詢是監控資料的消費介面。Collector 提供兩種查詢方式：CLI 直接操作 JSONL 檔案（grep + jq），和 HTTP 查詢 endpoint。兩種方式服務不同的消費者 — CLI 給開發者即時探索，HTTP endpoint 給自動化工具和非 CLI 使用者。&lt;/p>
&lt;h2 id="cli-查詢grep--jq">CLI 查詢：grep + jq&lt;/h2>
&lt;p>JSONL 格式的最大優勢是原生支援 Unix 文字處理工具。不需要額外的查詢語言、不需要客戶端工具、不需要連線到 database。&lt;/p>
&lt;h3 id="常見查詢模式">常見查詢模式&lt;/h3>
&lt;p>按事件類型過濾：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">grep &lt;span class="s1">&amp;#39;&amp;#34;type&amp;#34;:&amp;#34;error&amp;#34;&amp;#39;&lt;/span> events-2026-06-19.jsonl &lt;span class="p">|&lt;/span> jq .&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>按 namespace 過濾：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">grep &lt;span class="s1">&amp;#39;&amp;#34;name&amp;#34;:&amp;#34;terminal.connect&amp;#39;&lt;/span> events-2026-06-19.jsonl &lt;span class="p">|&lt;/span> jq .&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>按時間範圍過濾（跨檔案）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">cat events-2026-06-1&lt;span class="o">{&lt;/span>8,9&lt;span class="o">}&lt;/span>.jsonl &lt;span class="p">|&lt;/span> jq &lt;span class="s1">&amp;#39;select(.ts &amp;gt;= &amp;#34;2026-06-18T18:00:00&amp;#34;)&amp;#39;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>統計每種事件的數量：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">jq -r &lt;span class="s1">&amp;#39;.name&amp;#39;&lt;/span> events-2026-06-19.jsonl &lt;span class="p">|&lt;/span> sort &lt;span class="p">|&lt;/span> uniq -c &lt;span class="p">|&lt;/span> sort -rn&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="grep-友好的-jsonl-設計">grep 友好的 JSONL 設計&lt;/h3>
&lt;p>JSONL 的每行 JSON 結構影響 grep 的查詢效率和準確性。&lt;/p>
&lt;p>&lt;strong>把常用過濾欄位放在 JSON 的前面&lt;/strong>。grep 是字串匹配，把 &lt;code>type&lt;/code> 和 &lt;code>name&lt;/code> 放在行首讓 grep pattern 更簡單、誤匹配更少。&lt;/p>
&lt;p>&lt;strong>避免 JSON 值中包含雙引號&lt;/strong>。事件名稱和型別用簡單字串（不含特殊字元），讓 grep 的 pattern 不需要處理 escape。&lt;/p>
&lt;p>&lt;strong>每行 JSON 不換行&lt;/strong>。JSONL 的定義就是每行一個 JSON，但格式化工具可能自動加換行。寫入時用 &lt;code>json.Marshal&lt;/code>（Go）或 &lt;code>JSON.stringify&lt;/code>（JS）確保單行輸出。&lt;/p>
&lt;h2 id="http-查詢-endpoint">HTTP 查詢 endpoint&lt;/h2>
&lt;p>HTTP 查詢 endpoint 讓非 CLI 使用者（dashboard、自動化腳本、其他服務）能查詢事件資料。&lt;/p>
&lt;h3 id="endpoint-設計">Endpoint 設計&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">GET /v1/events?type=error&amp;amp;name=terminal.connect.*&amp;amp;from=2026-06-18T00:00:00Z&amp;amp;to=2026-06-19T00:00:00Z&amp;amp;limit=100&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>查詢參數：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>參數&lt;/th>
 &lt;th>說明&lt;/th>
 &lt;th>預設值&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>type&lt;/td>
 &lt;td>事件類型（event/error/metric/lifecycle）&lt;/td>
 &lt;td>全部&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>name&lt;/td>
 &lt;td>事件名稱（支援 &lt;code>*&lt;/code> 萬用字元）&lt;/td>
 &lt;td>全部&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>from&lt;/td>
 &lt;td>起始時間（ISO 8601）&lt;/td>
 &lt;td>24 小時前&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>to&lt;/td>
 &lt;td>結束時間（ISO 8601）&lt;/td>
 &lt;td>現在&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>limit&lt;/td>
 &lt;td>回傳筆數上限&lt;/td>
 &lt;td>100&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>offset&lt;/td>
 &lt;td>分頁偏移&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="回應格式">回應格式&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;events&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;v&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;timestamp&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-06-19T08:42:00Z&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;source&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;sdk&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;python&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;platform&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;macos&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;app&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;claude-hooks&amp;#34;&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;hook.failure&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;level&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;data&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;hook&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;branch-status-reminder&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;step&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;validation&amp;#34;&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;message&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;KeyError: &amp;#39;status&amp;#39;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;stack&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Traceback...&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;KeyError&amp;#34;&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;context&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;session_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;sess-abc-123&amp;#34;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;total&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">42&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;limit&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">100&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;offset&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>events&lt;/code> 陣列按 &lt;code>timestamp&lt;/code> 降序排列。&lt;code>total&lt;/code> 是符合篩選條件的全量筆數（不受 limit 截斷），讓呼叫端計算分頁（&lt;code>total_pages = ceil(total / limit)&lt;/code>）。分頁用 offset-based（&lt;code>offset=100&lt;/code> 取第二頁），適合資料量在十萬筆以下的場景。資料量大到 offset 效能不足時，改用 cursor-based（&lt;code>after=&amp;lt;last_event_id&amp;gt;&lt;/code>），但 cursor-based 是 PostgreSQL 層的演進，SQLite 層用 offset 足夠。&lt;/p>
&lt;h3 id="實作策略">實作策略&lt;/h3>
&lt;p>HTTP 查詢 endpoint 的底層實作可以直接讀取 JSONL 檔案 — 根據 from/to 確定要讀哪些日期的檔案，逐行 parse 並過濾。這個實作在資料量小（單日萬筆以下）時足夠快。&lt;/p>
&lt;p>當查詢效能成為問題時，在 JSONL 之上加一層索引（按 type/name 建立反向索引），或演進到 SQLite 儲存（見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進&lt;/a>）。&lt;/p></description><content:encoded><![CDATA[<p>查詢是監控資料的消費介面。Collector 提供兩種查詢方式：CLI 直接操作 JSONL 檔案（grep + jq），和 HTTP 查詢 endpoint。兩種方式服務不同的消費者 — CLI 給開發者即時探索，HTTP endpoint 給自動化工具和非 CLI 使用者。</p>
<h2 id="cli-查詢grep--jq">CLI 查詢：grep + jq</h2>
<p>JSONL 格式的最大優勢是原生支援 Unix 文字處理工具。不需要額外的查詢語言、不需要客戶端工具、不需要連線到 database。</p>
<h3 id="常見查詢模式">常見查詢模式</h3>
<p>按事件類型過濾：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">grep <span class="s1">&#39;&#34;type&#34;:&#34;error&#34;&#39;</span> events-2026-06-19.jsonl <span class="p">|</span> jq .</span></span></code></pre></div><p>按 namespace 過濾：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">grep <span class="s1">&#39;&#34;name&#34;:&#34;terminal.connect&#39;</span> events-2026-06-19.jsonl <span class="p">|</span> jq .</span></span></code></pre></div><p>按時間範圍過濾（跨檔案）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">cat events-2026-06-1<span class="o">{</span>8,9<span class="o">}</span>.jsonl <span class="p">|</span> jq <span class="s1">&#39;select(.ts &gt;= &#34;2026-06-18T18:00:00&#34;)&#39;</span></span></span></code></pre></div><p>統計每種事件的數量：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">jq -r <span class="s1">&#39;.name&#39;</span> events-2026-06-19.jsonl <span class="p">|</span> sort <span class="p">|</span> uniq -c <span class="p">|</span> sort -rn</span></span></code></pre></div><h3 id="grep-友好的-jsonl-設計">grep 友好的 JSONL 設計</h3>
<p>JSONL 的每行 JSON 結構影響 grep 的查詢效率和準確性。</p>
<p><strong>把常用過濾欄位放在 JSON 的前面</strong>。grep 是字串匹配，把 <code>type</code> 和 <code>name</code> 放在行首讓 grep pattern 更簡單、誤匹配更少。</p>
<p><strong>避免 JSON 值中包含雙引號</strong>。事件名稱和型別用簡單字串（不含特殊字元），讓 grep 的 pattern 不需要處理 escape。</p>
<p><strong>每行 JSON 不換行</strong>。JSONL 的定義就是每行一個 JSON，但格式化工具可能自動加換行。寫入時用 <code>json.Marshal</code>（Go）或 <code>JSON.stringify</code>（JS）確保單行輸出。</p>
<h2 id="http-查詢-endpoint">HTTP 查詢 endpoint</h2>
<p>HTTP 查詢 endpoint 讓非 CLI 使用者（dashboard、自動化腳本、其他服務）能查詢事件資料。</p>
<h3 id="endpoint-設計">Endpoint 設計</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">GET /v1/events?type=error&amp;name=terminal.connect.*&amp;from=2026-06-18T00:00:00Z&amp;to=2026-06-19T00:00:00Z&amp;limit=100</span></span></code></pre></div><p>查詢參數：</p>
<table>
  <thead>
      <tr>
          <th>參數</th>
          <th>說明</th>
          <th>預設值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>type</td>
          <td>事件類型（event/error/metric/lifecycle）</td>
          <td>全部</td>
      </tr>
      <tr>
          <td>name</td>
          <td>事件名稱（支援 <code>*</code> 萬用字元）</td>
          <td>全部</td>
      </tr>
      <tr>
          <td>from</td>
          <td>起始時間（ISO 8601）</td>
          <td>24 小時前</td>
      </tr>
      <tr>
          <td>to</td>
          <td>結束時間（ISO 8601）</td>
          <td>現在</td>
      </tr>
      <tr>
          <td>limit</td>
          <td>回傳筆數上限</td>
          <td>100</td>
      </tr>
      <tr>
          <td>offset</td>
          <td>分頁偏移</td>
          <td>0</td>
      </tr>
  </tbody>
</table>
<h3 id="回應格式">回應格式</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nt">&#34;events&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">      <span class="nt">&#34;v&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;error&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      <span class="nt">&#34;timestamp&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-06-19T08:42:00Z&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="nt">&#34;source&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;sdk&#34;</span><span class="p">:</span> <span class="s2">&#34;python&#34;</span><span class="p">,</span> <span class="nt">&#34;platform&#34;</span><span class="p">:</span> <span class="s2">&#34;macos&#34;</span><span class="p">,</span> <span class="nt">&#34;app&#34;</span><span class="p">:</span> <span class="s2">&#34;claude-hooks&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;hook.failure&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">      <span class="nt">&#34;level&#34;</span><span class="p">:</span> <span class="s2">&#34;error&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <span class="nt">&#34;data&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;hook&#34;</span><span class="p">:</span> <span class="s2">&#34;branch-status-reminder&#34;</span><span class="p">,</span> <span class="nt">&#34;step&#34;</span><span class="p">:</span> <span class="s2">&#34;validation&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">      <span class="nt">&#34;error&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;message&#34;</span><span class="p">:</span> <span class="s2">&#34;KeyError: &#39;status&#39;&#34;</span><span class="p">,</span> <span class="nt">&#34;stack&#34;</span><span class="p">:</span> <span class="s2">&#34;Traceback...&#34;</span><span class="p">,</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;KeyError&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">      <span class="nt">&#34;context&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;session_id&#34;</span><span class="p">:</span> <span class="s2">&#34;sess-abc-123&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="nt">&#34;total&#34;</span><span class="p">:</span> <span class="mi">42</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="nt">&#34;limit&#34;</span><span class="p">:</span> <span class="mi">100</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="nt">&#34;offset&#34;</span><span class="p">:</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>events</code> 陣列按 <code>timestamp</code> 降序排列。<code>total</code> 是符合篩選條件的全量筆數（不受 limit 截斷），讓呼叫端計算分頁（<code>total_pages = ceil(total / limit)</code>）。分頁用 offset-based（<code>offset=100</code> 取第二頁），適合資料量在十萬筆以下的場景。資料量大到 offset 效能不足時，改用 cursor-based（<code>after=&lt;last_event_id&gt;</code>），但 cursor-based 是 PostgreSQL 層的演進，SQLite 層用 offset 足夠。</p>
<h3 id="實作策略">實作策略</h3>
<p>HTTP 查詢 endpoint 的底層實作可以直接讀取 JSONL 檔案 — 根據 from/to 確定要讀哪些日期的檔案，逐行 parse 並過濾。這個實作在資料量小（單日萬筆以下）時足夠快。</p>
<p>當查詢效能成為問題時，在 JSONL 之上加一層索引（按 type/name 建立反向索引），或演進到 SQLite 儲存（見 <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a>）。</p>
<h2 id="聚合查詢">聚合查詢</h2>
<p>逐筆查詢回答「發生了什麼」，聚合查詢回答「發生了多少」。Error 調查的第一步是定位最頻繁的 error — 「哪些 error 最多」需要按 name 分群計數的聚合結果，逐筆列表在這個階段資訊量太大。</p>
<h3 id="endpoint-設計-1">Endpoint 設計</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">GET /v1/events/summary?type=error&amp;from=2026-06-18T00:00:00Z&amp;to=2026-06-19T00:00:00Z&amp;group_by=name</span></span></code></pre></div><p>回傳按 name 分群的統計：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;groups&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="p">{</span> <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;hook.failure&#34;</span><span class="p">,</span> <span class="nt">&#34;count&#34;</span><span class="p">:</span> <span class="mi">15</span><span class="p">,</span> <span class="nt">&#34;last_seen&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-06-19T08:42:00Z&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">{</span> <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;terminal.connect.failed&#34;</span><span class="p">,</span> <span class="nt">&#34;count&#34;</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span> <span class="nt">&#34;last_seen&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-06-19T07:10:00Z&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nt">&#34;total&#34;</span><span class="p">:</span> <span class="mi">18</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="nt">&#34;from&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-06-18T00:00:00Z&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="nt">&#34;to&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-06-19T00:00:00Z&#34;</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>查詢參數和逐筆查詢共用（type、name、from、to），額外的 <code>group_by</code> 指定分群欄位（name 或 type）。</p>
<h3 id="sql-實作">SQL 實作</h3>
<p>SQLite backend 下直接用 GROUP BY：</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="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">as</span><span class="w"> </span><span class="k">count</span><span class="p">,</span><span class="w"> </span><span class="k">MAX</span><span class="p">(</span><span class="k">timestamp</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">last_seen</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">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><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">AND</span><span class="w"> </span><span class="k">timestamp</span><span class="w"> </span><span class="k">BETWEEN</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="o">?</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><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="w">
</span></span></span><span class="line"><span class="ln">5</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="k">count</span><span class="w"> </span><span class="k">DESC</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">LIMIT</span><span class="w"> </span><span class="mi">100</span></span></span></code></pre></div><p>有 type + timestamp 複合索引時，這個查詢在 10 萬筆資料內的效能和逐筆查詢相當 — GROUP BY 在索引掃描後做，不需要全表掃描。</p>
<h3 id="和逐筆查詢的定位差異">和逐筆查詢的定位差異</h3>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>逐筆查詢 <code>/v1/events</code></th>
          <th>聚合查詢 <code>/v1/events/summary</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>回答</td>
          <td>發生了什麼（事件列表）</td>
          <td>發生了多少（統計摘要）</td>
      </tr>
      <tr>
          <td>用途</td>
          <td>看單筆 error 的 stack trace</td>
          <td>找出最頻繁的 error</td>
      </tr>
      <tr>
          <td>回傳</td>
          <td>事件陣列（含完整 JSON）</td>
          <td>分群摘要（name + count + last_seen）</td>
      </tr>
      <tr>
          <td>資料量</td>
          <td>大（完整事件 body）</td>
          <td>小（只有統計值）</td>
      </tr>
      <tr>
          <td>典型工作流</td>
          <td>聚合查詢找到問題 name → 逐筆查詢看細節</td>
          <td>首先使用</td>
      </tr>
  </tbody>
</table>
<p>兩者是互補的工作流 — 聚合查詢定位問題方向，逐筆查詢深入細節。Dashboard 的 Error 列表頁面直接消費聚合查詢的結果。</p>
<h2 id="cli-vs-http-的定位">CLI vs HTTP 的定位</h2>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>CLI (grep + jq)</th>
          <th>HTTP endpoint</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>使用者</td>
          <td>開發者</td>
          <td>自動化工具、dashboard</td>
      </tr>
      <tr>
          <td>適合</td>
          <td>即時探索、ad-hoc 查詢</td>
          <td>結構化查詢、程式化存取</td>
      </tr>
      <tr>
          <td>優勢</td>
          <td>零安裝、可組合</td>
          <td>遠端存取、標準化</td>
      </tr>
      <tr>
          <td>限制</td>
          <td>需要 SSH 存取 server</td>
          <td>需要 collector 啟動</td>
      </tr>
  </tbody>
</table>
<p>兩種介面共存 — CLI 用於開發者日常 debug，HTTP endpoint 用於自動化和遠端存取。兩者底層讀取同一份 JSONL 檔案，結果一致。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>JSONL 儲存的設計 → <a href="/blog/monitoring/04-collector/jsonl-storage/" data-link-title="JSONL 匯出與備份格式" data-link-desc="JSONL 作為匯出和備份格式的設計 — 人類可讀、grep 友好、SQLite 損壞時的重建來源">JSONL 儲存設計</a></li>
<li>Rule engine 的自動化處理 → <a href="/blog/monitoring/04-collector/rule-engine/" data-link-title="Rule engine 設計" data-link-desc="條件 → 動作 → 模板的三段式規則結構 — 讓 collector 從被動儲存變成主動回應">Rule engine 設計</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>
</ul>
]]></content:encoded></item><item><title>商業方案的事件類型對應</title><link>https://tarrragon.github.io/blog/monitoring/01-mental-model/commercial-event-mapping/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/01-mental-model/commercial-event-mapping/</guid><description>&lt;p>商業監控方案各自有不同的事件分類體系。理解它們的分類邏輯和四類事件（event / error / metric / lifecycle）的對應關係，才能在接入時正確映射自架方案的事件，避免資料遺漏或分類錯誤。&lt;/p>
&lt;h2 id="sentry">Sentry&lt;/h2>
&lt;p>Sentry 的核心概念是 error tracking，但已擴展到 performance monitoring 和 session replay。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>四類事件&lt;/th>
 &lt;th>Sentry 對應&lt;/th>
 &lt;th>說明&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Event&lt;/td>
 &lt;td>Breadcrumb&lt;/td>
 &lt;td>使用者操作記錄在 breadcrumb trail，附加在 error 上&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Error&lt;/td>
 &lt;td>Event（Exception type）&lt;/td>
 &lt;td>Sentry 的核心。自動捕獲 + 手動 captureException&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Metric&lt;/td>
 &lt;td>Transaction + Span&lt;/td>
 &lt;td>Performance monitoring 的度量單位&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Lifecycle&lt;/td>
 &lt;td>Breadcrumb（navigation）&lt;/td>
 &lt;td>app 生命週期記錄為 navigation/system breadcrumb&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Sentry 的設計假設是「error 是主角，其他事件是 error 的 context」。Event 和 lifecycle 都以 breadcrumb 形式附加在 error 報告上，獨立查看的能力有限。Breadcrumb 預設保留最近 100 條且不可獨立查詢 — 它是 error 報告的附件，不是獨立的事件資料庫。Metric 對應的 Transaction + Span 則有獨立的 Performance 頁面可以查看，和 error 是不同的 UI 入口。如果主要需求是行為分析而非 error tracking，Sentry 的 breadcrumb 模型可能不夠用。&lt;/p>
&lt;h2 id="firebase-crashlytics--analytics">Firebase Crashlytics + Analytics&lt;/h2>
&lt;p>Firebase 把 error tracking 和行為分析拆成兩個獨立產品。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>四類事件&lt;/th>
 &lt;th>Firebase 對應&lt;/th>
 &lt;th>說明&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Event&lt;/td>
 &lt;td>Analytics custom event&lt;/td>
 &lt;td>GA4 的 event，有 parameters 附加屬性&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Error&lt;/td>
 &lt;td>Crashlytics exception&lt;/td>
 &lt;td>fatal + non-fatal exception 分開處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Metric&lt;/td>
 &lt;td>Analytics event + parameters&lt;/td>
 &lt;td>用 event 的 parameters 記錄數值（無原生 metric）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Lifecycle&lt;/td>
 &lt;td>Analytics auto events&lt;/td>
 &lt;td>screen_view、app_open 等自動收集&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Firebase 的特點是 Crashlytics 和 Analytics 各自獨立運作 — error 資料在 Crashlytics console，行為資料在 Analytics console。Metric 沒有原生支援，只能用 Analytics event 的 parameters 欄位記錄數值（例如 &lt;code>event: 'page_load', parameters: {duration_ms: 320}&lt;/code>），查詢時需要在 BigQuery export 中自行聚合。兩個 console 之間的關聯需要手動（在 Crashlytics 的 custom key 中設定 user ID，再到 Analytics 用同一個 ID 查行為）。&lt;/p>
&lt;h2 id="datadog-rum">Datadog RUM&lt;/h2>
&lt;p>Datadog Real User Monitoring 從全棧 APM 的角度設計 client-side 監控。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>四類事件&lt;/th>
 &lt;th>Datadog RUM 對應&lt;/th>
 &lt;th>說明&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Event&lt;/td>
 &lt;td>Action&lt;/td>
 &lt;td>使用者操作（click、tap、scroll）自動或手動捕獲&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Error&lt;/td>
 &lt;td>Error&lt;/td>
 &lt;td>JS exception、network error、custom error&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Metric&lt;/td>
 &lt;td>Long Task + 自訂&lt;/td>
 &lt;td>長任務自動捕獲，自訂 metric 用 global context&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Lifecycle&lt;/td>
 &lt;td>View&lt;/td>
 &lt;td>頁面/畫面的進入和離開，自動偵測 SPA route 變換&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Datadog RUM 的特點是和 backend APM 的深度整合。Client-side 的 action 可以關聯到 server-side 的 trace，形成從按鈕點擊到 database query 的完整鏈路。自架方案通常做不到這個深度的跨層關聯。&lt;/p></description><content:encoded><![CDATA[<p>商業監控方案各自有不同的事件分類體系。理解它們的分類邏輯和四類事件（event / error / metric / lifecycle）的對應關係，才能在接入時正確映射自架方案的事件，避免資料遺漏或分類錯誤。</p>
<h2 id="sentry">Sentry</h2>
<p>Sentry 的核心概念是 error tracking，但已擴展到 performance monitoring 和 session replay。</p>
<table>
  <thead>
      <tr>
          <th>四類事件</th>
          <th>Sentry 對應</th>
          <th>說明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Event</td>
          <td>Breadcrumb</td>
          <td>使用者操作記錄在 breadcrumb trail，附加在 error 上</td>
      </tr>
      <tr>
          <td>Error</td>
          <td>Event（Exception type）</td>
          <td>Sentry 的核心。自動捕獲 + 手動 captureException</td>
      </tr>
      <tr>
          <td>Metric</td>
          <td>Transaction + Span</td>
          <td>Performance monitoring 的度量單位</td>
      </tr>
      <tr>
          <td>Lifecycle</td>
          <td>Breadcrumb（navigation）</td>
          <td>app 生命週期記錄為 navigation/system breadcrumb</td>
      </tr>
  </tbody>
</table>
<p>Sentry 的設計假設是「error 是主角，其他事件是 error 的 context」。Event 和 lifecycle 都以 breadcrumb 形式附加在 error 報告上，獨立查看的能力有限。Breadcrumb 預設保留最近 100 條且不可獨立查詢 — 它是 error 報告的附件，不是獨立的事件資料庫。Metric 對應的 Transaction + Span 則有獨立的 Performance 頁面可以查看，和 error 是不同的 UI 入口。如果主要需求是行為分析而非 error tracking，Sentry 的 breadcrumb 模型可能不夠用。</p>
<h2 id="firebase-crashlytics--analytics">Firebase Crashlytics + Analytics</h2>
<p>Firebase 把 error tracking 和行為分析拆成兩個獨立產品。</p>
<table>
  <thead>
      <tr>
          <th>四類事件</th>
          <th>Firebase 對應</th>
          <th>說明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Event</td>
          <td>Analytics custom event</td>
          <td>GA4 的 event，有 parameters 附加屬性</td>
      </tr>
      <tr>
          <td>Error</td>
          <td>Crashlytics exception</td>
          <td>fatal + non-fatal exception 分開處理</td>
      </tr>
      <tr>
          <td>Metric</td>
          <td>Analytics event + parameters</td>
          <td>用 event 的 parameters 記錄數值（無原生 metric）</td>
      </tr>
      <tr>
          <td>Lifecycle</td>
          <td>Analytics auto events</td>
          <td>screen_view、app_open 等自動收集</td>
      </tr>
  </tbody>
</table>
<p>Firebase 的特點是 Crashlytics 和 Analytics 各自獨立運作 — error 資料在 Crashlytics console，行為資料在 Analytics console。Metric 沒有原生支援，只能用 Analytics event 的 parameters 欄位記錄數值（例如 <code>event: 'page_load', parameters: {duration_ms: 320}</code>），查詢時需要在 BigQuery export 中自行聚合。兩個 console 之間的關聯需要手動（在 Crashlytics 的 custom key 中設定 user ID，再到 Analytics 用同一個 ID 查行為）。</p>
<h2 id="datadog-rum">Datadog RUM</h2>
<p>Datadog Real User Monitoring 從全棧 APM 的角度設計 client-side 監控。</p>
<table>
  <thead>
      <tr>
          <th>四類事件</th>
          <th>Datadog RUM 對應</th>
          <th>說明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Event</td>
          <td>Action</td>
          <td>使用者操作（click、tap、scroll）自動或手動捕獲</td>
      </tr>
      <tr>
          <td>Error</td>
          <td>Error</td>
          <td>JS exception、network error、custom error</td>
      </tr>
      <tr>
          <td>Metric</td>
          <td>Long Task + 自訂</td>
          <td>長任務自動捕獲，自訂 metric 用 global context</td>
      </tr>
      <tr>
          <td>Lifecycle</td>
          <td>View</td>
          <td>頁面/畫面的進入和離開，自動偵測 SPA route 變換</td>
      </tr>
  </tbody>
</table>
<p>Datadog RUM 的特點是和 backend APM 的深度整合。Client-side 的 action 可以關聯到 server-side 的 trace，形成從按鈕點擊到 database query 的完整鏈路。自架方案通常做不到這個深度的跨層關聯。</p>
<h2 id="接入策略">接入策略</h2>
<p>接入商業方案時的映射原則：</p>
<p><strong>自架事件名稱是 source of truth</strong>。商業方案的事件名稱是自架名稱的映射，不是取代。映射邏輯集中在一個 adapter 層，商業方案更換時只改 adapter。</p>
<p><strong>不要為了配合商業方案改變自架的分類</strong>。Sentry 把 event 記錄為 breadcrumb 不代表自架方案也要把 event 降級成 error 的附屬品。自架的四類分類是語意正確的，商業方案的分類是它自己的產品設計。</p>
<p><strong>同時接入多個方案時做去重</strong>。Error 同時發到 Sentry 和 Crashlytics 會產生重複。在 adapter 層控制「哪類事件發到哪個方案」，避免同一個事件在多個 dashboard 出現。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>四類事件的定義 → <a href="/blog/monitoring/01-mental-model/four-event-types/" data-link-title="四類事件的完整定義" data-link-desc="Event / Error / Metric / Lifecycle 四類事件各自的語意、觸發時機和典型用途 — 分類是監控體系的統一語言">四類事件的完整定義</a></li>
<li>商業方案的深入比較 → <a href="/blog/monitoring/06-commercial-comparison/" data-link-title="模組六：商業方案對照" data-link-desc="Sentry / Crashlytics / Datadog RUM / Mixpanel — 自架 vs 商業的功能和成本取捨">模組六 商業方案比較</a></li>
<li>事件命名規範 → <a href="/blog/monitoring/01-mental-model/event-naming-convention/" data-link-title="事件命名規範" data-link-desc="namespace.action 格式的事件命名、命名一致性的工程價值、和商業方案命名慣例的對應">事件命名規範</a></li>
</ul>
]]></content:encoded></item><item><title>模組三：SDK 設計模式</title><link>https://tarrragon.github.io/blog/monitoring/03-sdk-design/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/03-sdk-design/</guid><description>&lt;p>回答「怎麼在各平台埋點」。三個 SDK（JS/Flutter/Python）共用同一套事件格式，公開 API 保持一致。&lt;/p>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> SDK 公開 API 設計（init / event / error / metric / flush / close）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 自動攔截機制（JS window.onerror / Flutter FlutterError / Python sys.excepthook）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 攢批送出策略（flush interval / buffer size / flush on close）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 離線 buffer 與重試（FIFO 丟棄 / 本地 persistence / 恢復後補發的取捨）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> SDK redaction helper（模組七的實作層）&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">testing 模組三 協議整合測試&lt;/a>：SDK 的 HTTP POST 行為需要 protocol test&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安&lt;/a>：redaction 在 SDK 端做&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>：mock 遮蔽機制影響 SDK 的 auto-intercept 行為驗證&lt;/li>
&lt;li>實作 repo：tarrragon/monitor 的 sdk-js / sdk-flutter / sdk-python&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「怎麼在各平台埋點」。三個 SDK（JS/Flutter/Python）共用同一套事件格式，公開 API 保持一致。</p>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> SDK 公開 API 設計（init / event / error / metric / flush / close）</li>
<li><input checked="" disabled="" type="checkbox"> 自動攔截機制（JS window.onerror / Flutter FlutterError / Python sys.excepthook）</li>
<li><input checked="" disabled="" type="checkbox"> 攢批送出策略（flush interval / buffer size / flush on close）</li>
<li><input checked="" disabled="" type="checkbox"> 離線 buffer 與重試（FIFO 丟棄 / 本地 persistence / 恢復後補發的取捨）</li>
<li><input checked="" disabled="" type="checkbox"> SDK redaction helper（模組七的實作層）</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">testing 模組三 協議整合測試</a>：SDK 的 HTTP POST 行為需要 protocol test</li>
<li>→ <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a>：redaction 在 SDK 端做</li>
<li>← <a href="/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一 測試策略</a>：mock 遮蔽機制影響 SDK 的 auto-intercept 行為驗證</li>
<li>實作 repo：tarrragon/monitor 的 sdk-js / sdk-flutter / sdk-python</li>
</ul>
]]></content:encoded></item><item><title>攢批送出策略</title><link>https://tarrragon.github.io/blog/monitoring/03-sdk-design/batch-flush/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/03-sdk-design/batch-flush/</guid><description>&lt;p>攢批送出策略控制事件從 SDK 內部 buffer 送到 collector 的時機。事件產生後先進入記憶體 buffer，累積到一定數量或間隔一定時間後，一次性透過 HTTP POST 送出整批事件。攢批的目的是減少網路請求次數 — 100 筆事件合併成一個 HTTP 請求，比 100 個獨立請求的網路開銷低。&lt;/p>
&lt;h2 id="三個觸發條件">三個觸發條件&lt;/h2>
&lt;h3 id="時間觸發flush-interval">時間觸發（flush interval）&lt;/h3>
&lt;p>固定間隔自動 flush。SDK 在 init 時啟動計時器，每隔 N 毫秒檢查 buffer 是否有待發事件，有則送出。&lt;/p>
&lt;p>合理的間隔範圍：10-60 秒。間隔太短（1 秒）接近逐筆送出，失去攢批的效益；間隔太長（5 分鐘）可能讓事件延遲到達 collector，影響即時監控和告警的反應速度。&lt;/p>
&lt;p>自用工具場景下 30 秒是合理的預設 — 事件量低，30 秒的延遲對 debug 分析沒有實質影響。商業產品可以降到 10 秒以獲得更接近即時的 error 告警。&lt;/p>
&lt;h3 id="數量觸發buffer-size">數量觸發（buffer size）&lt;/h3>
&lt;p>Buffer 內的事件數量達到上限時立即 flush。Buffer size 設定為一次 HTTP POST 的合理 payload 大小對應的事件數量。&lt;/p>
&lt;p>合理的數量範圍：50-200 筆。數量太少（10 筆）頻繁觸發 flush；數量太多（1000 筆）單次 HTTP POST 的 payload 過大，增加傳輸失敗的風險（超時、記憶體）。&lt;/p>
&lt;p>數量觸發和時間觸發互為備援。高頻事件場景（使用者快速操作）靠數量觸發避免 buffer 溢出；低頻事件場景（使用者長時間閒置）靠時間觸發確保事件在合理時間內送出。&lt;/p>
&lt;h3 id="關閉觸發flush-on-close">關閉觸發（flush on close）&lt;/h3>
&lt;p>SDK close 時強制 flush buffer 中所有剩餘事件。這是最後一道保障 — app 關閉後 buffer 中未送出的事件就永久遺失了。&lt;/p>
&lt;p>close flush 的挑戰是時間限制。iOS app 進入背景後約 5 秒會被系統 suspend，Android 的限制更嚴格。Close flush 必須在這個時間窗口內完成網路請求。如果 buffer 中事件太多導致 flush 超時，需要截斷 — 送出最近的 N 筆，放棄較舊的。&lt;/p>
&lt;h2 id="buffer-管理">Buffer 管理&lt;/h2>
&lt;h3 id="記憶體-buffer">記憶體 buffer&lt;/h3>
&lt;p>Buffer 在記憶體中維護一個事件陣列。新事件 append 到尾端，flush 時取出整個陣列送出並清空。&lt;/p>
&lt;p>記憶體 buffer 的上限應該設定為 buffer size 的 2-3 倍（允許 1-2 次 flush 失敗後累積的事件）。超過上限時丟棄最舊的事件（FIFO），保留最新的 — 最新的事件對 debug 和即時分析的價值更高。&lt;/p>
&lt;h3 id="離線-buffer">離線 buffer&lt;/h3>
&lt;p>網路不可用時，事件累積在記憶體 buffer 中。如果離線時間超過記憶體 buffer 容量，需要離線 persistence — 見 &lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">離線 buffer 與重試&lt;/a>。&lt;/p>
&lt;h2 id="flush-失敗處理">Flush 失敗處理&lt;/h2>
&lt;p>HTTP POST 失敗時（網路中斷、server 回 5xx、超時），事件保留在 buffer 中等待下一次 flush 重試。不立即重試 — 連續失敗通常代表網路問題或 server 問題，立即重試只會增加負載。&lt;/p>
&lt;p>重試次數有上限（3 次）。超過重試上限的事件被丟棄，記錄一筆 &lt;code>sdk.flush.dropped&lt;/code> metric 事件（這筆 metric 本身也進 buffer，在下次成功 flush 時送出）。&lt;/p>
&lt;h3 id="sdk-對-collector-回應的處理">SDK 對 collector 回應的處理&lt;/h3>
&lt;p>SDK 只需要判斷 HTTP status code 就知道怎麼處理 buffer，不需要解析 response body 的細節。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Status&lt;/th>
 &lt;th>SDK 行為&lt;/th>
 &lt;th>理由&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>200&lt;/td>
 &lt;td>清除已送出的 buffer&lt;/td>
 &lt;td>全部成功&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>207&lt;/td>
 &lt;td>清除 buffer + 記錄 warning log&lt;/td>
 &lt;td>合法事件已被接受；失敗事件是 schema 問題，重試也不會過&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>400&lt;/td>
 &lt;td>清除 buffer + 記錄 error log&lt;/td>
 &lt;td>Schema 問題重試也不會過，保留在 buffer 只會擋住後續事件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>503&lt;/td>
 &lt;td>保留 buffer + 等待 &lt;code>retry_after&lt;/code> 秒&lt;/td>
 &lt;td>collector 暫時不可用，事件本身沒問題&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>其他（network error / timeout）&lt;/td>
 &lt;td>保留 buffer + 下次 flush 重試&lt;/td>
 &lt;td>暫時性問題，重試有機會成功&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>207 和 400 都清 buffer 的關鍵判斷：Schema 驗證失敗是 SDK 端產出了不合規的事件，問題在 SDK 的事件建構邏輯（程式碼 bug），不在 collector 或網路 — 重試相同事件永遠不會過。SDK 把失敗事件的 error 訊息記到 warning/error log 供開發者排查，然後放行後續事件。&lt;/p></description><content:encoded><![CDATA[<p>攢批送出策略控制事件從 SDK 內部 buffer 送到 collector 的時機。事件產生後先進入記憶體 buffer，累積到一定數量或間隔一定時間後，一次性透過 HTTP POST 送出整批事件。攢批的目的是減少網路請求次數 — 100 筆事件合併成一個 HTTP 請求，比 100 個獨立請求的網路開銷低。</p>
<h2 id="三個觸發條件">三個觸發條件</h2>
<h3 id="時間觸發flush-interval">時間觸發（flush interval）</h3>
<p>固定間隔自動 flush。SDK 在 init 時啟動計時器，每隔 N 毫秒檢查 buffer 是否有待發事件，有則送出。</p>
<p>合理的間隔範圍：10-60 秒。間隔太短（1 秒）接近逐筆送出，失去攢批的效益；間隔太長（5 分鐘）可能讓事件延遲到達 collector，影響即時監控和告警的反應速度。</p>
<p>自用工具場景下 30 秒是合理的預設 — 事件量低，30 秒的延遲對 debug 分析沒有實質影響。商業產品可以降到 10 秒以獲得更接近即時的 error 告警。</p>
<h3 id="數量觸發buffer-size">數量觸發（buffer size）</h3>
<p>Buffer 內的事件數量達到上限時立即 flush。Buffer size 設定為一次 HTTP POST 的合理 payload 大小對應的事件數量。</p>
<p>合理的數量範圍：50-200 筆。數量太少（10 筆）頻繁觸發 flush；數量太多（1000 筆）單次 HTTP POST 的 payload 過大，增加傳輸失敗的風險（超時、記憶體）。</p>
<p>數量觸發和時間觸發互為備援。高頻事件場景（使用者快速操作）靠數量觸發避免 buffer 溢出；低頻事件場景（使用者長時間閒置）靠時間觸發確保事件在合理時間內送出。</p>
<h3 id="關閉觸發flush-on-close">關閉觸發（flush on close）</h3>
<p>SDK close 時強制 flush buffer 中所有剩餘事件。這是最後一道保障 — app 關閉後 buffer 中未送出的事件就永久遺失了。</p>
<p>close flush 的挑戰是時間限制。iOS app 進入背景後約 5 秒會被系統 suspend，Android 的限制更嚴格。Close flush 必須在這個時間窗口內完成網路請求。如果 buffer 中事件太多導致 flush 超時，需要截斷 — 送出最近的 N 筆，放棄較舊的。</p>
<h2 id="buffer-管理">Buffer 管理</h2>
<h3 id="記憶體-buffer">記憶體 buffer</h3>
<p>Buffer 在記憶體中維護一個事件陣列。新事件 append 到尾端，flush 時取出整個陣列送出並清空。</p>
<p>記憶體 buffer 的上限應該設定為 buffer size 的 2-3 倍（允許 1-2 次 flush 失敗後累積的事件）。超過上限時丟棄最舊的事件（FIFO），保留最新的 — 最新的事件對 debug 和即時分析的價值更高。</p>
<h3 id="離線-buffer">離線 buffer</h3>
<p>網路不可用時，事件累積在記憶體 buffer 中。如果離線時間超過記憶體 buffer 容量，需要離線 persistence — 見 <a href="/blog/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">離線 buffer 與重試</a>。</p>
<h2 id="flush-失敗處理">Flush 失敗處理</h2>
<p>HTTP POST 失敗時（網路中斷、server 回 5xx、超時），事件保留在 buffer 中等待下一次 flush 重試。不立即重試 — 連續失敗通常代表網路問題或 server 問題，立即重試只會增加負載。</p>
<p>重試次數有上限（3 次）。超過重試上限的事件被丟棄，記錄一筆 <code>sdk.flush.dropped</code> metric 事件（這筆 metric 本身也進 buffer，在下次成功 flush 時送出）。</p>
<h3 id="sdk-對-collector-回應的處理">SDK 對 collector 回應的處理</h3>
<p>SDK 只需要判斷 HTTP status code 就知道怎麼處理 buffer，不需要解析 response body 的細節。</p>
<table>
  <thead>
      <tr>
          <th>Status</th>
          <th>SDK 行為</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>200</td>
          <td>清除已送出的 buffer</td>
          <td>全部成功</td>
      </tr>
      <tr>
          <td>207</td>
          <td>清除 buffer + 記錄 warning log</td>
          <td>合法事件已被接受；失敗事件是 schema 問題，重試也不會過</td>
      </tr>
      <tr>
          <td>400</td>
          <td>清除 buffer + 記錄 error log</td>
          <td>Schema 問題重試也不會過，保留在 buffer 只會擋住後續事件</td>
      </tr>
      <tr>
          <td>503</td>
          <td>保留 buffer + 等待 <code>retry_after</code> 秒</td>
          <td>collector 暫時不可用，事件本身沒問題</td>
      </tr>
      <tr>
          <td>其他（network error / timeout）</td>
          <td>保留 buffer + 下次 flush 重試</td>
          <td>暫時性問題，重試有機會成功</td>
      </tr>
  </tbody>
</table>
<p>207 和 400 都清 buffer 的關鍵判斷：Schema 驗證失敗是 SDK 端產出了不合規的事件，問題在 SDK 的事件建構邏輯（程式碼 bug），不在 collector 或網路 — 重試相同事件永遠不會過。SDK 把失敗事件的 error 訊息記到 warning/error log 供開發者排查，然後放行後續事件。</p>
<p>503 保留 buffer 的關鍵判斷：collector 暫時不可用是基礎設施問題（SQLite busy timeout、背壓），事件本身合法，等 collector 恢復後重試會成功。<code>retry_after</code> 由 collector 在回應中指定，SDK 用這個值設定下次 flush 的最小等待時間。</p>
<h2 id="batch-格式">Batch 格式</h2>
<p>SDK 在 flush 時把 buffer 中所有事件包裝成一個 batch，帶上 <code>batch_id</code> 送出。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;batch_id&#34;</span><span class="p">:</span> <span class="s2">&#34;019537a0-7b2c-7def-8a2b-3c4d5e6f7890&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;events&#34;</span><span class="p">:</span> <span class="p">[</span> <span class="err">...</span> <span class="p">]</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>batch_id</code> 由 SDK 在 flush 時產生。使用 UUID v7（<code>uuid.uuid7()</code>，Python 3.14+ 標準庫）——時間戳前綴保證有序（debug 時按 batch_id 排序即時間順序），隨機後綴保證唯一（高負載下多個 SDK 同時 flush 不碰撞）。用途是追蹤和 debug（collector log 中標記同一批事件的來源）。Collector 不依賴 batch_id 做去重 — 同一批事件被 SDK 重試時會帶不同的 batch_id（每次 flush 重新產生），collector 按事件內容（timestamp + source + name）判斷是否重複。</p>
<p>UUID v7 而非時間戳格式的選型理由：時間戳格式（<code>b-{YYYYMMDD}-{HHMMSSfff}</code>）在同毫秒多次 flush 時會碰撞，雖然 MVP 的 debug 用途碰撞無害，但 batch_id 碰撞在後續版本的離線補發去重場景（見 <a href="/blog/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">離線 buffer 與重試</a>）會造成歧義。UUID v7 兼顧有序和唯一，一次到位。</p>
<h2 id="heartbeat-和-flush-的整合">Heartbeat 和 flush 的整合</h2>
<p>DevOps dashboard 需要 <code>sdk.heartbeat</code> 事件判斷 SDK 是否存活。Heartbeat 不需要獨立的 timer — 整合在 flush timer 中：</p>
<p>flush timer 觸發時，如果 buffer 為空且距上次 heartbeat 超過設定間隔（預設 5 分鐘），自動注入一筆 <code>sdk.heartbeat</code> lifecycle 事件後送出。App idle 時仍有心跳但不多一個 timer；app 活躍時 heartbeat 被正常事件的 flush 取代（buffer 不會為空）。</p>
<p>Heartbeat 間隔由 SDK init config 的 <code>heartbeatInterval</code> 設定。設為 0 停用 heartbeat。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>離線場景的處理 → <a href="/blog/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">離線 buffer 與重試</a></li>
<li>SDK 公開 API → <a href="/blog/monitoring/03-sdk-design/public-api/" data-link-title="SDK 公開 API 設計" data-link-desc="init / event / error / metric / flush / close 六個方法構成 SDK 的完整生命週期 — 跨平台共用相同 API 介面">SDK 公開 API 設計</a></li>
<li>Collector 端如何接收批次事件 → <a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 架構</a></li>
</ul>
]]></content:encoded></item><item><title>斷網環境的監控與可觀測性</title><link>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-monitoring/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-monitoring/</guid><description>&lt;p>斷網環境不能用 Datadog、New Relic、Sentry Cloud、PagerDuty Cloud 這些 SaaS 監控服務——它們全部需要往外發送資料。監控的三個核心能力（metric 收集、log 彙整、告警通知）全部要用 self-hosted 的開源工具在隔離網路內搭建。原則跟連網環境相同（metric 跟資源同生命週期、alarm 要連到動作），差別在工具的部署和儲存規劃要自己管。&lt;/p>
&lt;h2 id="metric-收集prometheus--grafana">Metric 收集：Prometheus + Grafana&lt;/h2>
&lt;p>Prometheus 是 pull-based 的 metric 收集系統——它主動去 scrape 各服務的 metric endpoint，不需要服務往外推資料。這個架構天然適合斷網：所有流量都在內網、不需要出站連線。&lt;/p>
&lt;h3 id="離線安裝">離線安裝&lt;/h3>
&lt;p>Prometheus 和 Grafana 都是單一二進位或容器映像，離線安裝跟&lt;a href="https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-container/" data-link-title="斷網環境的容器與映像管理" data-link-desc="Private registry 架設、映像搬運（docker save/load、skopeo）、base image 更新週期、離線漏洞掃描">映像搬運&lt;/a>相同的流程：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 外部：下載 release binary&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">wget https://github.com/prometheus/prometheus/releases/download/v2.53.0/prometheus-2.53.0.linux-amd64.tar.gz
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">wget https://dl.grafana.com/oss/release/grafana-11.1.0.linux-amd64.tar.gz
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1"># 搬運後解壓、設定 systemd service&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">tar xzf prometheus-2.53.0.linux-amd64.tar.gz
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">sudo mv prometheus-2.53.0.linux-amd64 /opt/prometheus&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果用容器部署，先把映像搬進內部 registry 再 pull：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 內部：從內部 registry 啟動&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">docker run -d -p 9090:9090 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> -v /etc/prometheus:/etc/prometheus &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> -v /data/prometheus:/prometheus &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> registry.internal:5000/prometheus:v2.53.0&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="scrape-設定">Scrape 設定&lt;/h3>
&lt;p>Prometheus 的 &lt;code>prometheus.yml&lt;/code> 定義要 scrape 的目標。斷網環境通常用 static config（手動列出目標）而非 service discovery（需要雲端 API）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="nt">scrape_configs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">job_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;node-exporter&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">static_configs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">targets&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s1">&amp;#39;server-01:9100&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s1">&amp;#39;server-02:9100&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s1">&amp;#39;db-01:9100&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">job_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;app&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">static_configs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">targets&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s1">&amp;#39;app-01:8080&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s1">&amp;#39;app-02:8080&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metrics_path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;/metrics&amp;#39;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>新增機器時手動把它加進 targets 清單。如果用 Consul（內網 service discovery），Prometheus 支援 Consul SD、可以自動發現新服務。&lt;/p>
&lt;h3 id="node-exporter">Node Exporter&lt;/h3>
&lt;p>每台需要監控的 Linux 機器裝一個 node_exporter（單一二進位、無依賴），暴露 CPU、記憶體、磁碟、網路等系統 metric。離線安裝同理——下載 binary、搬運、解壓、設成 service。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 搬運後安裝&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">tar xzf node_exporter-1.8.1.linux-amd64.tar.gz
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">sudo cp node_exporter-1.8.1.linux-amd64/node_exporter /usr/local/bin/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">sudo useradd --no-create-home --shell /bin/false node_exporter
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1"># 建立 systemd service（略）&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="log-收集loki-或-elk">Log 收集：Loki 或 ELK&lt;/h2>
&lt;h3 id="grafana-loki輕量">Grafana Loki（輕量）&lt;/h3>
&lt;p>Loki 是 Grafana 生態的 log 彙整系統，架構類似 Prometheus（pull/push 都支援），但儲存的是 log stream 而非 metric。它不索引 log 內容（只索引 label），所以儲存成本遠低於 Elasticsearch。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c"># loki-config.yaml 基本設定&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">auth_enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">server&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">http_listen_port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3100&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">storage_config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">filesystem&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">directory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/data/loki/chunks&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">schema_config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">configs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">from&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="ld">2024-01-01&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">store&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">tsdb&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">object_store&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">filesystem&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">schema&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v13&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prefix&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">index_&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">period&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">24h&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>搭配 Promtail（log 收集 agent）在每台機器上收集 log 並推送到 Loki：&lt;/p></description><content:encoded><![CDATA[<p>斷網環境不能用 Datadog、New Relic、Sentry Cloud、PagerDuty Cloud 這些 SaaS 監控服務——它們全部需要往外發送資料。監控的三個核心能力（metric 收集、log 彙整、告警通知）全部要用 self-hosted 的開源工具在隔離網路內搭建。原則跟連網環境相同（metric 跟資源同生命週期、alarm 要連到動作），差別在工具的部署和儲存規劃要自己管。</p>
<h2 id="metric-收集prometheus--grafana">Metric 收集：Prometheus + Grafana</h2>
<p>Prometheus 是 pull-based 的 metric 收集系統——它主動去 scrape 各服務的 metric endpoint，不需要服務往外推資料。這個架構天然適合斷網：所有流量都在內網、不需要出站連線。</p>
<h3 id="離線安裝">離線安裝</h3>
<p>Prometheus 和 Grafana 都是單一二進位或容器映像，離線安裝跟<a href="/blog/infra/air-gapped/air-gapped-container/" data-link-title="斷網環境的容器與映像管理" data-link-desc="Private registry 架設、映像搬運（docker save/load、skopeo）、base image 更新週期、離線漏洞掃描">映像搬運</a>相同的流程：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 外部：下載 release binary</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">wget https://github.com/prometheus/prometheus/releases/download/v2.53.0/prometheus-2.53.0.linux-amd64.tar.gz
</span></span><span class="line"><span class="ln">3</span><span class="cl">wget https://dl.grafana.com/oss/release/grafana-11.1.0.linux-amd64.tar.gz
</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"># 搬運後解壓、設定 systemd service</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">tar xzf prometheus-2.53.0.linux-amd64.tar.gz
</span></span><span class="line"><span class="ln">7</span><span class="cl">sudo mv prometheus-2.53.0.linux-amd64 /opt/prometheus</span></span></code></pre></div><p>如果用容器部署，先把映像搬進內部 registry 再 pull：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 內部：從內部 registry 啟動</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker run -d -p 9090:9090 <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  -v /etc/prometheus:/etc/prometheus <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  -v /data/prometheus:/prometheus <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  registry.internal:5000/prometheus:v2.53.0</span></span></code></pre></div><h3 id="scrape-設定">Scrape 設定</h3>
<p>Prometheus 的 <code>prometheus.yml</code> 定義要 scrape 的目標。斷網環境通常用 static config（手動列出目標）而非 service discovery（需要雲端 API）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">scrape_configs</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="nt">job_name</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;node-exporter&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="nt">static_configs</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="nt">targets</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="s1">&#39;server-01:9100&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">          </span>- <span class="s1">&#39;server-02:9100&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">          </span>- <span class="s1">&#39;db-01:9100&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span>- <span class="nt">job_name</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;app&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span><span class="nt">static_configs</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="nt">targets</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">          </span>- <span class="s1">&#39;app-01:8080&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">          </span>- <span class="s1">&#39;app-02:8080&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">    </span><span class="nt">metrics_path</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;/metrics&#39;</span></span></span></code></pre></div><p>新增機器時手動把它加進 targets 清單。如果用 Consul（內網 service discovery），Prometheus 支援 Consul SD、可以自動發現新服務。</p>
<h3 id="node-exporter">Node Exporter</h3>
<p>每台需要監控的 Linux 機器裝一個 node_exporter（單一二進位、無依賴），暴露 CPU、記憶體、磁碟、網路等系統 metric。離線安裝同理——下載 binary、搬運、解壓、設成 service。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 搬運後安裝</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">tar xzf node_exporter-1.8.1.linux-amd64.tar.gz
</span></span><span class="line"><span class="ln">3</span><span class="cl">sudo cp node_exporter-1.8.1.linux-amd64/node_exporter /usr/local/bin/
</span></span><span class="line"><span class="ln">4</span><span class="cl">sudo useradd --no-create-home --shell /bin/false node_exporter
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 建立 systemd service（略）</span></span></span></code></pre></div><h2 id="log-收集loki-或-elk">Log 收集：Loki 或 ELK</h2>
<h3 id="grafana-loki輕量">Grafana Loki（輕量）</h3>
<p>Loki 是 Grafana 生態的 log 彙整系統，架構類似 Prometheus（pull/push 都支援），但儲存的是 log stream 而非 metric。它不索引 log 內容（只索引 label），所以儲存成本遠低於 Elasticsearch。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># loki-config.yaml 基本設定</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">auth_enabled</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="nt">server</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="nt">http_listen_port</span><span class="p">:</span><span class="w"> </span><span class="m">3100</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="nt">storage_config</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="nt">filesystem</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="nt">directory</span><span class="p">:</span><span class="w"> </span><span class="l">/data/loki/chunks</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="nt">schema_config</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="nt">configs</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="nt">from</span><span class="p">:</span><span class="w"> </span><span class="ld">2024-01-01</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span><span class="nt">store</span><span class="p">:</span><span class="w"> </span><span class="l">tsdb</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">      </span><span class="nt">object_store</span><span class="p">:</span><span class="w"> </span><span class="l">filesystem</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">      </span><span class="nt">schema</span><span class="p">:</span><span class="w"> </span><span class="l">v13</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">      </span><span class="nt">index</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="nt">prefix</span><span class="p">:</span><span class="w"> </span><span class="l">index_</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">        </span><span class="nt">period</span><span class="p">:</span><span class="w"> </span><span class="l">24h</span></span></span></code></pre></div><p>搭配 Promtail（log 收集 agent）在每台機器上收集 log 並推送到 Loki：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># promtail-config.yaml</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">clients</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="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">http://loki.internal:3100/loki/api/v1/push</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="nt">scrape_configs</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="nt">job_name</span><span class="p">:</span><span class="w"> </span><span class="l">system</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">static_configs</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="nt">targets</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">localhost]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">        </span><span class="nt">labels</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="nt">job</span><span class="p">:</span><span class="w"> </span><span class="l">syslog</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">          </span><span class="nt">__path__</span><span class="p">:</span><span class="w"> </span><span class="l">/var/log/*.log</span></span></span></code></pre></div><h3 id="elk-stack功能豐富">ELK Stack（功能豐富）</h3>
<p>Elasticsearch + Logstash + Kibana 是功能最完整的 log 平台，但資源消耗大（Elasticsearch 建議至少 4GB RAM 起跳）。適合需要全文搜索 log 內容的場景。</p>
<p>離線安裝：Elastic 提供離線安裝包（<code>.deb</code> / <code>.rpm</code>），或用 Docker 映像。三個組件都要搬運。</p>
<p>選型判準：5 台以下的小環境用 Loki（輕量、跟 Prometheus + Grafana 同一套 dashboard）。需要全文搜索、已有 ELK 經驗的團隊用 ELK。</p>
<h2 id="告警沒有外部-webhook-怎麼通知">告警：沒有外部 webhook 怎麼通知</h2>
<p>連網環境的告警通常發到 Slack webhook、PagerDuty API、或 email relay service。斷網環境這些路徑都不通。</p>
<h3 id="內部-smtp">內部 SMTP</h3>
<p>如果隔離網路內有 email server（很多企業內網有 Exchange 或 Postfix），Prometheus Alertmanager 可以發 email 告警：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># alertmanager.yml</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">route</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="nt">receiver</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;email-team&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="nt">receivers</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="nt">name</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;email-team&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">email_configs</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="nt">to</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;oncall@internal.corp&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">        </span><span class="nt">from</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;alertmanager@internal.corp&#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="nt">smarthost</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;smtp.internal.corp:25&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">        </span><span class="nt">require_tls</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span></span></span></code></pre></div><h3 id="內部即時通訊">內部即時通訊</h3>
<p>如果內網有 Mattermost（Slack 的 self-hosted 替代）或 Rocket.Chat，Alertmanager 可以用 webhook 發送到這些工具的 incoming webhook endpoint。</p>
<h3 id="實體告警">實體告警</h3>
<p>極端情境（沒有 email、沒有 chat）：Alertmanager 把告警寫到檔案或資料庫、搭配值班制度定期查看。或用 Grafana 的 dashboard + 控制室大螢幕，值班人員直接看板。</p>
<p>告警的設計原則跟連網環境相同——symptom-based（錯誤率、延遲）優先於 cause-based（CPU、記憶體），閾值設計避免告警疲勞。差別在通知的到達速度可能慢一些（email 比 Slack push 慢），所以閾值要稍微保守（提早告警）。</p>
<h2 id="metric-與-log-的儲存規劃">Metric 與 Log 的儲存規劃</h2>
<p>SaaS 監控的儲存是雲端自動擴展的。Self-hosted 的儲存要自己規劃——磁碟滿了 Prometheus 就停止收集、Loki 就停止寫入。</p>
<h3 id="容量估算">容量估算</h3>
<p>Prometheus 的儲存量取決於 series 數量 × scrape 間隔 × 保留天數。粗估公式：</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">每日儲存 ≈ active_series × sample_size(2B) × (86400 / scrape_interval) × compression_ratio(~0.1)</span></span></code></pre></div><p>1 萬個 active series、15 秒 scrape interval、保留 30 天 ≈ 約 5GB。保留 90 天 ≈ 約 15GB。</p>
<p>Loki 的儲存量取決於 log 流量。粗估：每天 10GB 的 raw log 在 Loki 壓縮後約 1-2GB，保留 30 天 ≈ 30-60GB。</p>
<h3 id="retention-設定">Retention 設定</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="c"># prometheus.yml</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="nt">global</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="nt">scrape_interval</span><span class="p">:</span><span class="w"> </span><span class="l">15s</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="nt">storage</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="nt">tsdb</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="nt">retention.time</span><span class="p">:</span><span class="w"> </span><span class="l">30d</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w">    </span><span class="nt">retention.size</span><span class="p">:</span><span class="w"> </span><span class="l">10GB </span><span class="w"> </span><span class="c"># 以先到的為準</span></span></span></code></pre></div><p>超過容量時 Prometheus 自動刪除最舊的資料。設定 retention 前先確認磁碟空間足夠——斷網環境擴容磁碟的流程（採購 + 安裝）可能需要週到月級的時間。</p>
<h2 id="ntp-時間同步">NTP 時間同步</h2>
<p>斷網環境容易被忽略的一個問題是時間同步。沒有 NTP server（<code>pool.ntp.org</code>）可連的機器，時鐘會漂移——幾天後各台機器的時間差可能達到秒級。當 Prometheus 收到的 metric timestamp 跟 Loki 收到的 log timestamp 有幾秒落差，事故排查時 metric 跟 log 對不上。</p>
<p>解法是在隔離網路內架一台 NTP server，所有機器從它同步：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 內部 NTP server（chrony）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># /etc/chrony/chrony.conf</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nb">local</span> stratum <span class="m">10</span>         <span class="c1"># 沒有外部來源時、自己當 stratum 10</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">allow 10.0.0.0/16        <span class="c1"># 允許內部網段同步</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># 其他機器指向內部 NTP</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">server ntp.internal iburst</span></span></code></pre></div><p>如果隔離網路的閘道可以開 NTP（UDP 123），讓閘道從外部 NTP 同步、內部機器從閘道同步，時間精度可以維持在毫秒級。</p>
<p>時程參考：Prometheus + Grafana + Alertmanager 的初次建置約需 1-2 天。Loki + Promtail 約需半天到一天。NTP server 約需 2 小時。後續維護主要是 Prometheus/Loki 版本更新的搬運（每次 1-2 小時）和儲存容量監控。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">斷網環境的通用原則</a>：監控工具的離線安裝走 content ferry 模式</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-container/" data-link-title="斷網環境的容器與映像管理" data-link-desc="Private registry 架設、映像搬運（docker save/load、skopeo）、base image 更新週期、離線漏洞掃描">斷網環境的容器管理</a>：Prometheus/Grafana/Loki 的容器映像搬運</li>
<li>→ <a href="/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">模組六：可觀測性與 log</a>：連網環境的可觀測性 IaC</li>
<li>→ <a href="/blog/infra/takeover/legacy-external-monitoring/" data-link-title="無 SSH 環境的監控與告警" data-link-desc="無 SSH 環境沒辦法裝 agent、沒辦法串 log pipeline，用外部 HTTP check、錯誤追蹤服務與效能基線建立最低成本的監控能力">無 SSH 環境的監控與告警</a>：另一個極端——完全外部監控</li>
<li>→ <a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">Monitoring 04：Collector 架構與部署</a>：SDK 和 Collector 的應用層監控，斷網環境需要把 Collector endpoint 指向 self-hosted backend</li>
<li>→ <a href="/blog/monitoring/06-commercial-comparison/self-hosted-vs-commercial/" data-link-title="自架 vs 商業的判斷決策表" data-link-desc="使用者數、網路範圍、功能需求、合規要求四個維度判斷該自架還是用商業方案">Monitoring 06：Self-hosted vs Commercial</a>：斷網環境只能走 self-hosted 路線</li>
</ul>
]]></content:encoded></item><item><title>Attribution</title><link>https://tarrragon.github.io/blog/monitoring/08-business-analytics/attribution/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/08-business-analytics/attribution/</guid><description>&lt;p>Attribution（歸因）回答「使用者的轉換應該歸功於哪個渠道或觸點」。使用者可能先看到 Facebook 廣告、再 Google 搜尋、最後直接輸入網址完成購買 — 三個渠道都接觸了使用者，轉換功勞歸誰決定了行銷預算的分配。&lt;/p>
&lt;h2 id="歸因模型">歸因模型&lt;/h2>
&lt;h3 id="last-touch-attribution">Last-touch attribution&lt;/h3>
&lt;p>把轉換功勞全部歸給使用者轉換前最後接觸的渠道。上例中功勞歸「直接輸入網址」。&lt;/p>
&lt;p>優點：實作最簡單 — 只需要記錄轉換事件的 referrer 或 UTM 參數。&lt;/p>
&lt;p>缺點：忽略了前面渠道的貢獻。Facebook 廣告讓使用者第一次知道產品，但在 last-touch 模型中功勞為零。長期使用 last-touch 會導致行銷預算過度集中在「最後一步」渠道（品牌搜尋、直接訪問），低估「認知階段」渠道（展示廣告、社群媒體）。&lt;/p>
&lt;h3 id="first-touch-attribution">First-touch attribution&lt;/h3>
&lt;p>把轉換功勞全部歸給使用者第一次接觸的渠道。上例中功勞歸 Facebook 廣告。&lt;/p>
&lt;p>優點：強調「獲客」渠道的貢獻，適合評估品牌認知和獲客效率。&lt;/p>
&lt;p>缺點：忽略了後續渠道的推進作用。使用者第一次看到廣告但沒行動，可能是後續的 Google 搜尋才促成轉換。&lt;/p>
&lt;h3 id="multi-touch-attribution">Multi-touch attribution&lt;/h3>
&lt;p>把轉換功勞分配給使用者轉換路徑上的所有渠道。分配方式有多種：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>線性歸因&lt;/strong>：每個渠道平均分配。三個渠道各得 33.3%。&lt;/li>
&lt;li>&lt;strong>時間衰減&lt;/strong>：離轉換越近的渠道得到越多功勞。&lt;/li>
&lt;li>&lt;strong>Position-based（U 型）&lt;/strong>：第一個和最後一個渠道各得 40%，中間渠道分 20%。&lt;/li>
&lt;li>&lt;strong>資料驅動（data-driven）&lt;/strong>：用機器學習模型從歷史資料學習每個渠道的貢獻。需要大量資料。&lt;/li>
&lt;/ul>
&lt;h2 id="技術實作">技術實作&lt;/h2>
&lt;p>Attribution 的技術實作需要解決兩個問題：跨 session 的使用者識別，和觸點的記錄。&lt;/p>
&lt;h3 id="跨-session-識別">跨 session 識別&lt;/h3>
&lt;p>同一個使用者在不同 session、不同裝置、不同瀏覽器上的行為需要關聯到同一個人。&lt;/p>
&lt;p>Web 端用 cookie（first-party）或 login ID 關聯。Mobile 端用 device ID 或 login ID。跨裝置關聯需要使用者登入 — 未登入的使用者在不同裝置上是不同的匿名 ID。&lt;/p>
&lt;h3 id="觸點記錄">觸點記錄&lt;/h3>
&lt;p>每次使用者接觸產品的渠道需要記錄。Web 端記錄 referrer、UTM 參數（&lt;code>utm_source&lt;/code>、&lt;code>utm_medium&lt;/code>、&lt;code>utm_campaign&lt;/code>）。Mobile 端記錄 deep link 參數、app store 來源（需要 attribution SDK 如 AppsFlyer、Adjust）。&lt;/p>
&lt;h2 id="自架方案的歸因能力">自架方案的歸因能力&lt;/h2>
&lt;p>自架 collector 能做基礎的 last-touch attribution — 在轉換事件的屬性中記錄 referrer 和 UTM 參數。&lt;/p>
&lt;p>Multi-touch attribution 需要跨 session 的使用者行為歷史，實作複雜度顯著上升。如果 multi-touch 是核心需求，商業方案（GA4、Mixpanel、AppsFlyer）通常比自架更實用。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>A/B test 驗證渠道效果 → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/ab-test-statistics/" data-link-title="A/B Test 的統計基礎" data-link-desc="假設檢定、樣本量計算、多重比較校正 — A/B test 不只是「比較兩個數字」，統計方法決定結論是否可靠">A/B test 的統計基礎&lt;/a>&lt;/li>
&lt;li>使用者分群 → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/cohort-analysis/" data-link-title="Cohort Analysis" data-link-desc="按共同特徵分群、比較不同群體的留存率和行為差異 — 從「平均值」到「誰在用、誰離開了」">Cohort analysis&lt;/a>&lt;/li>
&lt;li>行為事件設計 → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/behavior-event-design/" data-link-title="行為事件設計" data-link-desc="事件命名規範、屬性設計、funnel 定義 — 行為分析的品質取決於事件設計的品質">行為事件設計&lt;/a>&lt;/li>
&lt;li>客戶取得成本 → &lt;a href="https://tarrragon.github.io/blog/business/knowledge-cards/cac/" data-link-title="CAC" data-link-desc="說明獲客成本及其對商業模式可行性的決定作用">CAC&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Attribution（歸因）回答「使用者的轉換應該歸功於哪個渠道或觸點」。使用者可能先看到 Facebook 廣告、再 Google 搜尋、最後直接輸入網址完成購買 — 三個渠道都接觸了使用者，轉換功勞歸誰決定了行銷預算的分配。</p>
<h2 id="歸因模型">歸因模型</h2>
<h3 id="last-touch-attribution">Last-touch attribution</h3>
<p>把轉換功勞全部歸給使用者轉換前最後接觸的渠道。上例中功勞歸「直接輸入網址」。</p>
<p>優點：實作最簡單 — 只需要記錄轉換事件的 referrer 或 UTM 參數。</p>
<p>缺點：忽略了前面渠道的貢獻。Facebook 廣告讓使用者第一次知道產品，但在 last-touch 模型中功勞為零。長期使用 last-touch 會導致行銷預算過度集中在「最後一步」渠道（品牌搜尋、直接訪問），低估「認知階段」渠道（展示廣告、社群媒體）。</p>
<h3 id="first-touch-attribution">First-touch attribution</h3>
<p>把轉換功勞全部歸給使用者第一次接觸的渠道。上例中功勞歸 Facebook 廣告。</p>
<p>優點：強調「獲客」渠道的貢獻，適合評估品牌認知和獲客效率。</p>
<p>缺點：忽略了後續渠道的推進作用。使用者第一次看到廣告但沒行動，可能是後續的 Google 搜尋才促成轉換。</p>
<h3 id="multi-touch-attribution">Multi-touch attribution</h3>
<p>把轉換功勞分配給使用者轉換路徑上的所有渠道。分配方式有多種：</p>
<ul>
<li><strong>線性歸因</strong>：每個渠道平均分配。三個渠道各得 33.3%。</li>
<li><strong>時間衰減</strong>：離轉換越近的渠道得到越多功勞。</li>
<li><strong>Position-based（U 型）</strong>：第一個和最後一個渠道各得 40%，中間渠道分 20%。</li>
<li><strong>資料驅動（data-driven）</strong>：用機器學習模型從歷史資料學習每個渠道的貢獻。需要大量資料。</li>
</ul>
<h2 id="技術實作">技術實作</h2>
<p>Attribution 的技術實作需要解決兩個問題：跨 session 的使用者識別，和觸點的記錄。</p>
<h3 id="跨-session-識別">跨 session 識別</h3>
<p>同一個使用者在不同 session、不同裝置、不同瀏覽器上的行為需要關聯到同一個人。</p>
<p>Web 端用 cookie（first-party）或 login ID 關聯。Mobile 端用 device ID 或 login ID。跨裝置關聯需要使用者登入 — 未登入的使用者在不同裝置上是不同的匿名 ID。</p>
<h3 id="觸點記錄">觸點記錄</h3>
<p>每次使用者接觸產品的渠道需要記錄。Web 端記錄 referrer、UTM 參數（<code>utm_source</code>、<code>utm_medium</code>、<code>utm_campaign</code>）。Mobile 端記錄 deep link 參數、app store 來源（需要 attribution SDK 如 AppsFlyer、Adjust）。</p>
<h2 id="自架方案的歸因能力">自架方案的歸因能力</h2>
<p>自架 collector 能做基礎的 last-touch attribution — 在轉換事件的屬性中記錄 referrer 和 UTM 參數。</p>
<p>Multi-touch attribution 需要跨 session 的使用者行為歷史，實作複雜度顯著上升。如果 multi-touch 是核心需求，商業方案（GA4、Mixpanel、AppsFlyer）通常比自架更實用。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>A/B test 驗證渠道效果 → <a href="/blog/monitoring/08-business-analytics/ab-test-statistics/" data-link-title="A/B Test 的統計基礎" data-link-desc="假設檢定、樣本量計算、多重比較校正 — A/B test 不只是「比較兩個數字」，統計方法決定結論是否可靠">A/B test 的統計基礎</a></li>
<li>使用者分群 → <a href="/blog/monitoring/08-business-analytics/cohort-analysis/" data-link-title="Cohort Analysis" data-link-desc="按共同特徵分群、比較不同群體的留存率和行為差異 — 從「平均值」到「誰在用、誰離開了」">Cohort analysis</a></li>
<li>行為事件設計 → <a href="/blog/monitoring/08-business-analytics/behavior-event-design/" data-link-title="行為事件設計" data-link-desc="事件命名規範、屬性設計、funnel 定義 — 行為分析的品質取決於事件設計的品質">行為事件設計</a></li>
<li>客戶取得成本 → <a href="/blog/business/knowledge-cards/cac/" data-link-title="CAC" data-link-desc="說明獲客成本及其對商業模式可行性的決定作用">CAC</a></li>
</ul>
]]></content:encoded></item><item><title>Datadog RUM</title><link>https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/datadog-rum/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/datadog-rum/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>跟 Backend 04 的分工&lt;/strong>：本文從 client-side RUM 角度說明 Datadog 的全棧追蹤、四種 RUM 事件與 session replay。Server-side 的 APM 平台治理（agent 配置、成本治理、OTel 相容遷移、從 New Relic 或 Grafana Stack 遷移）見 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/vendors/datadog/" data-link-title="Datadog" data-link-desc="All-in-one SaaS 觀測平台、APM / Logs / Metrics / RUM / Security">Backend 04 Datadog vendor page&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;p>Datadog Real User Monitoring（RUM）從全棧 APM 的角度設計 client-side 監控。核心特徵是 client 端的使用者操作可以關聯到 server 端的 trace，形成從按鈕點擊到 database query 的完整請求鏈路。&lt;/p>
&lt;h2 id="全棧追蹤">全棧追蹤&lt;/h2>
&lt;p>Datadog RUM 的 SDK 在 HTTP 請求中自動注入 trace context header。Server 端的 Datadog APM agent 讀取 header，把 server 端的 trace 和 client 端的 action 關聯。&lt;/p>
&lt;p>這個能力在 debug「API 慢」的問題時特別有用 — 從 client 端看到「這個按鈕的回應時間 3 秒」，點進去看到 server 端的 trace 顯示「database query 佔了 2.8 秒」。自架方案和 Sentry 都做不到這個深度的跨層關聯。&lt;/p>
&lt;p>前提是 server 端也使用 Datadog APM。如果 server 端用其他 APM（New Relic、Elastic APM），client-server 的關聯需要自行實作或用 OpenTelemetry 橋接。&lt;/p>
&lt;h2 id="四種-rum-事件">四種 RUM 事件&lt;/h2>
&lt;p>Datadog RUM 收集四種事件，和自架方案的四類事件有對應關係（&lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/commercial-event-mapping/" data-link-title="商業方案的事件類型對應" data-link-desc="Sentry / Crashlytics / GA4 / Datadog RUM 各自如何對應四類事件 — 理解商業方案的分類邏輯才能正確接入">模組一 商業方案對應&lt;/a>）：&lt;/p>
&lt;p>&lt;strong>View&lt;/strong>：頁面或畫面的載入和離開。自動偵測 SPA 的 route 變換，對應 lifecycle 事件。&lt;/p>
&lt;p>&lt;strong>Action&lt;/strong>：使用者操作。自動捕獲 click、tap、scroll，可手動記錄自訂 action，對應 event 事件。&lt;/p>
&lt;p>&lt;strong>Error&lt;/strong>：JS exception、network error、自訂 error，對應 error 事件。&lt;/p>
&lt;p>&lt;strong>Long Task&lt;/strong>：執行時間超過 50ms 的任務（阻塞主執行緒），對應 metric 事件。&lt;/p>
&lt;h2 id="定價">定價&lt;/h2>
&lt;p>Datadog RUM 按 session 數計費（每個 session 是一次使用者訪問）。和 Sentry 按事件數計費不同 — session 計費讓成本更可預測（不會因為單次訪問觸發大量事件而費用暴增）。&lt;/p>
&lt;p>Datadog 的完整方案（RUM + APM + Logs + Infrastructure）費用較高，適合已經用 Datadog 做 server-side 監控的團隊。單獨用 RUM 而 server 端用其他方案，失去全棧追蹤的優勢。&lt;/p>
&lt;p>Datadog RUM 的全棧追蹤能力獨一無二，但如果只需要行為分析而非 APM，&lt;a href="https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/mixpanel-amplitude/" data-link-title="Mixpanel / Amplitude" data-link-desc="行為分析專用方案 vs 通用監控的差異 — Mixpanel 和 Amplitude 的 funnel / cohort / retention 分析能力">Mixpanel / Amplitude&lt;/a> 是更輕量的選擇。和 &lt;a href="https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/sentry-deep-dive/" data-link-title="Sentry 深入" data-link-desc="Error tracking &amp;#43; performance monitoring &amp;#43; session replay 的架構 — Sentry 從 error-first 出發如何擴展到全面可觀測性">Sentry&lt;/a> 的定位差異在於 Sentry 聚焦 error tracking、Datadog 聚焦全棧關聯。&lt;a href="https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/self-hosted-vs-commercial/" data-link-title="自架 vs 商業的判斷決策表" data-link-desc="使用者數、網路範圍、功能需求、合規要求四個維度判斷該自架還是用商業方案">自架 vs 商業的判斷決策表&lt;/a>從使用者規模和功能需求維度做系統性比較。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>跟 Backend 04 的分工</strong>：本文從 client-side RUM 角度說明 Datadog 的全棧追蹤、四種 RUM 事件與 session replay。Server-side 的 APM 平台治理（agent 配置、成本治理、OTel 相容遷移、從 New Relic 或 Grafana Stack 遷移）見 <a href="/blog/backend/04-observability/vendors/datadog/" data-link-title="Datadog" data-link-desc="All-in-one SaaS 觀測平台、APM / Logs / Metrics / RUM / Security">Backend 04 Datadog vendor page</a>。</p></blockquote>
<p>Datadog Real User Monitoring（RUM）從全棧 APM 的角度設計 client-side 監控。核心特徵是 client 端的使用者操作可以關聯到 server 端的 trace，形成從按鈕點擊到 database query 的完整請求鏈路。</p>
<h2 id="全棧追蹤">全棧追蹤</h2>
<p>Datadog RUM 的 SDK 在 HTTP 請求中自動注入 trace context header。Server 端的 Datadog APM agent 讀取 header，把 server 端的 trace 和 client 端的 action 關聯。</p>
<p>這個能力在 debug「API 慢」的問題時特別有用 — 從 client 端看到「這個按鈕的回應時間 3 秒」，點進去看到 server 端的 trace 顯示「database query 佔了 2.8 秒」。自架方案和 Sentry 都做不到這個深度的跨層關聯。</p>
<p>前提是 server 端也使用 Datadog APM。如果 server 端用其他 APM（New Relic、Elastic APM），client-server 的關聯需要自行實作或用 OpenTelemetry 橋接。</p>
<h2 id="四種-rum-事件">四種 RUM 事件</h2>
<p>Datadog RUM 收集四種事件，和自架方案的四類事件有對應關係（<a href="/blog/monitoring/01-mental-model/commercial-event-mapping/" data-link-title="商業方案的事件類型對應" data-link-desc="Sentry / Crashlytics / GA4 / Datadog RUM 各自如何對應四類事件 — 理解商業方案的分類邏輯才能正確接入">模組一 商業方案對應</a>）：</p>
<p><strong>View</strong>：頁面或畫面的載入和離開。自動偵測 SPA 的 route 變換，對應 lifecycle 事件。</p>
<p><strong>Action</strong>：使用者操作。自動捕獲 click、tap、scroll，可手動記錄自訂 action，對應 event 事件。</p>
<p><strong>Error</strong>：JS exception、network error、自訂 error，對應 error 事件。</p>
<p><strong>Long Task</strong>：執行時間超過 50ms 的任務（阻塞主執行緒），對應 metric 事件。</p>
<h2 id="定價">定價</h2>
<p>Datadog RUM 按 session 數計費（每個 session 是一次使用者訪問）。和 Sentry 按事件數計費不同 — session 計費讓成本更可預測（不會因為單次訪問觸發大量事件而費用暴增）。</p>
<p>Datadog 的完整方案（RUM + APM + Logs + Infrastructure）費用較高，適合已經用 Datadog 做 server-side 監控的團隊。單獨用 RUM 而 server 端用其他方案，失去全棧追蹤的優勢。</p>
<p>Datadog RUM 的全棧追蹤能力獨一無二，但如果只需要行為分析而非 APM，<a href="/blog/monitoring/06-commercial-comparison/mixpanel-amplitude/" data-link-title="Mixpanel / Amplitude" data-link-desc="行為分析專用方案 vs 通用監控的差異 — Mixpanel 和 Amplitude 的 funnel / cohort / retention 分析能力">Mixpanel / Amplitude</a> 是更輕量的選擇。和 <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> 的定位差異在於 Sentry 聚焦 error tracking、Datadog 聚焦全棧關聯。<a href="/blog/monitoring/06-commercial-comparison/self-hosted-vs-commercial/" data-link-title="自架 vs 商業的判斷決策表" data-link-desc="使用者數、網路範圍、功能需求、合規要求四個維度判斷該自架還是用商業方案">自架 vs 商業的判斷決策表</a>從使用者規模和功能需求維度做系統性比較。</p>
]]></content:encoded></item><item><title>Go 平台適配</title><link>https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/go-platform/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/go-platform/</guid><description>&lt;p>Go 的 monitoring SDK 和其他平台 SDK 的定位不同。JS / Flutter / Python SDK 是 client-side 的事件上報工具，Go SDK 更常用在 server-side — 包括 collector 本身的自身監控。Go 的 goroutine 並行模型、signal handling 機制和 HTTP server 的 graceful shutdown 是 Go 環境中的三個核心適配問題。&lt;/p>
&lt;h2 id="graceful-shutdown">Graceful shutdown&lt;/h2>
&lt;p>Go 程式收到 SIGTERM 或 SIGINT 時需要在退出前完成清理：flush 剩餘的 buffer、關閉網路連線、寫入最後的 lifecycle 事件。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">stop&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">signal&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NotifyContext&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Background&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">syscall&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SIGTERM&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">syscall&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SIGINT&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="k">defer&lt;/span> &lt;span class="nf">stop&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">// signal received, start graceful shutdown&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="nx">monitor&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Close&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WithTimeout&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Background&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="mi">5&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Second&lt;/span>&lt;span class="p">))&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>graceful shutdown 的時間窗口由部署環境決定。Kubernetes 的預設 terminationGracePeriodSeconds 是 30 秒，Docker 的 stop timeout 是 10 秒。SDK 的 Close 方法接受 context 讓呼叫端控制超時。&lt;/p>
&lt;h3 id="http-server-的-shutdown-順序">HTTP server 的 shutdown 順序&lt;/h3>
&lt;p>如果 Go 程式同時是 HTTP server 和 monitoring SDK 的使用者，shutdown 順序需要正確：&lt;/p>
&lt;ol>
&lt;li>停止接受新連線（&lt;code>server.Shutdown(ctx)&lt;/code>）&lt;/li>
&lt;li>等待進行中的請求完成&lt;/li>
&lt;li>flush 監控 buffer（&lt;code>monitor.Close(ctx)&lt;/code>）&lt;/li>
&lt;li>關閉 log 和其他資源&lt;/li>
&lt;/ol>
&lt;p>如果先 close monitor 再 shutdown server，進行中的請求產生的事件會在 monitor 已關閉後嘗試送出，被靜默丟棄。&lt;/p>
&lt;h2 id="signal-handling">Signal handling&lt;/h2>
&lt;p>Go 的 &lt;code>signal.Notify&lt;/code> 和 &lt;code>signal.NotifyContext&lt;/code> 是接收 OS signal 的標準方式。SDK 在 init 時不應該自己註冊 signal handler — 這會和應用程式的 signal handling 衝突（Go 的 signal handler 是先到先得，後註冊的覆蓋先註冊的）。&lt;/p>
&lt;p>SDK 端的適配方式是提供 &lt;code>Close&lt;/code> 方法讓應用程式在自己的 signal handler 中呼叫，而非 SDK 內部攔截 signal。應用程式控制 shutdown 流程，SDK 只負責在被告知關閉時 flush 和清理。&lt;/p>
&lt;h3 id="panic-recovery">panic recovery&lt;/h3>
&lt;p>Go 的 panic 會終止當前 goroutine。如果 panic 發生在 main goroutine 且沒有 recover，程式直接退出，SDK 的 buffer 中的事件遺失。&lt;/p>
&lt;p>SDK 可以提供 &lt;code>monitor.RecoverAndReport()&lt;/code> 讓開發者在 goroutine 的入口用 &lt;code>defer monitor.RecoverAndReport()&lt;/code> 攔截 panic，記錄 error 事件後再 re-panic（保持原有的 crash 行為）。&lt;/p>
&lt;p>HTTP handler 的 panic 可以用 middleware 攔截：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">monitorMiddleware&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">next&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Handler&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Handler&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">HandlerFunc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">monitor&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RecoverAndReport&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">next&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ServeHTTP&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="http-server-自身監控">HTTP server 自身監控&lt;/h2>
&lt;p>Go 常用來寫 collector 本身。Collector 需要監控自己的健康狀態 — 請求處理速率、錯誤率、goroutine 數量、記憶體使用量。&lt;/p></description><content:encoded><![CDATA[<p>Go 的 monitoring SDK 和其他平台 SDK 的定位不同。JS / Flutter / Python SDK 是 client-side 的事件上報工具，Go SDK 更常用在 server-side — 包括 collector 本身的自身監控。Go 的 goroutine 並行模型、signal handling 機制和 HTTP server 的 graceful shutdown 是 Go 環境中的三個核心適配問題。</p>
<h2 id="graceful-shutdown">Graceful shutdown</h2>
<p>Go 程式收到 SIGTERM 或 SIGINT 時需要在退出前完成清理：flush 剩餘的 buffer、關閉網路連線、寫入最後的 lifecycle 事件。</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="nx">ctx</span><span class="p">,</span> <span class="nx">stop</span> <span class="o">:=</span> <span class="nx">signal</span><span class="p">.</span><span class="nf">NotifyContext</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">syscall</span><span class="p">.</span><span class="nx">SIGTERM</span><span class="p">,</span> <span class="nx">syscall</span><span class="p">.</span><span class="nx">SIGINT</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">defer</span> <span class="nf">stop</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">// signal received, start graceful shutdown</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">monitor</span><span class="p">.</span><span class="nf">Close</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">WithTimeout</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="mi">5</span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">))</span></span></span></code></pre></div><p>graceful shutdown 的時間窗口由部署環境決定。Kubernetes 的預設 terminationGracePeriodSeconds 是 30 秒，Docker 的 stop timeout 是 10 秒。SDK 的 Close 方法接受 context 讓呼叫端控制超時。</p>
<h3 id="http-server-的-shutdown-順序">HTTP server 的 shutdown 順序</h3>
<p>如果 Go 程式同時是 HTTP server 和 monitoring SDK 的使用者，shutdown 順序需要正確：</p>
<ol>
<li>停止接受新連線（<code>server.Shutdown(ctx)</code>）</li>
<li>等待進行中的請求完成</li>
<li>flush 監控 buffer（<code>monitor.Close(ctx)</code>）</li>
<li>關閉 log 和其他資源</li>
</ol>
<p>如果先 close monitor 再 shutdown server，進行中的請求產生的事件會在 monitor 已關閉後嘗試送出，被靜默丟棄。</p>
<h2 id="signal-handling">Signal handling</h2>
<p>Go 的 <code>signal.Notify</code> 和 <code>signal.NotifyContext</code> 是接收 OS signal 的標準方式。SDK 在 init 時不應該自己註冊 signal handler — 這會和應用程式的 signal handling 衝突（Go 的 signal handler 是先到先得，後註冊的覆蓋先註冊的）。</p>
<p>SDK 端的適配方式是提供 <code>Close</code> 方法讓應用程式在自己的 signal handler 中呼叫，而非 SDK 內部攔截 signal。應用程式控制 shutdown 流程，SDK 只負責在被告知關閉時 flush 和清理。</p>
<h3 id="panic-recovery">panic recovery</h3>
<p>Go 的 panic 會終止當前 goroutine。如果 panic 發生在 main goroutine 且沒有 recover，程式直接退出，SDK 的 buffer 中的事件遺失。</p>
<p>SDK 可以提供 <code>monitor.RecoverAndReport()</code> 讓開發者在 goroutine 的入口用 <code>defer monitor.RecoverAndReport()</code> 攔截 panic，記錄 error 事件後再 re-panic（保持原有的 crash 行為）。</p>
<p>HTTP handler 的 panic 可以用 middleware 攔截：</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">func</span> <span class="nf">monitorMiddleware</span><span class="p">(</span><span class="nx">next</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span><span class="p">)</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">http</span><span class="p">.</span><span class="nf">HandlerFunc</span><span class="p">(</span><span class="kd">func</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">defer</span> <span class="nx">monitor</span><span class="p">.</span><span class="nf">RecoverAndReport</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">next</span><span class="p">.</span><span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">r</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h2 id="http-server-自身監控">HTTP server 自身監控</h2>
<p>Go 常用來寫 collector 本身。Collector 需要監控自己的健康狀態 — 請求處理速率、錯誤率、goroutine 數量、記憶體使用量。</p>
<p>Collector 的自身監控和接收外部事件是兩個獨立的管線。自身監控的 metric 可以寫入獨立的 JSONL 檔案（和外部事件分開），或透過 Go 的 <code>expvar</code> / <code>runtime.ReadMemStats</code> 暴露為 HTTP endpoint。</p>
<p>自身監控的關鍵指標：</p>
<ul>
<li><code>collector.events.received</code>：每秒收到的事件數</li>
<li><code>collector.events.invalid</code>：schema 驗證失敗的事件數</li>
<li><code>collector.storage.write_duration_ms</code>：寫入 JSONL 的耗時</li>
<li><code>collector.goroutines</code>：goroutine 數量（洩漏偵測）</li>
<li><code>collector.memory.alloc_mb</code>：記憶體使用量</li>
</ul>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>跨平台 timestamp 一致性 → <a href="/blog/monitoring/05-platform-adaptation/cross-platform-timestamp/" data-link-title="跨平台 timestamp 一致性" data-link-desc="時區、精度、clock drift — 不同平台產生的 timestamp 在 collector 端需要能正確比對和排序">跨平台 timestamp 一致性</a></li>
<li>Collector 的架構設計 → <a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計</a></li>
<li>SDK 公開 API 的 Close 方法 → <a href="/blog/monitoring/03-sdk-design/public-api/" data-link-title="SDK 公開 API 設計" data-link-desc="init / event / error / metric / flush / close 六個方法構成 SDK 的完整生命週期 — 跨平台共用相同 API 介面">模組三 SDK 公開 API</a></li>
</ul>
]]></content:encoded></item><item><title>RFM</title><link>https://tarrragon.github.io/blog/monitoring/knowledge-cards/rfm/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/knowledge-cards/rfm/</guid><description>&lt;p>RFM 的核心概念是「用 Recency（最近活躍度）、Frequency（使用頻率）、Monetary（貢獻價值）三個維度把使用者分成可操作的群組」。每個維度獨立評分後組合，識別出忠實客戶、潛在流失、新使用者、休眠使用者等群組。可先對照 &lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/cohort-analysis/" data-link-title="Cohort Analysis" data-link-desc="說明把使用者按共同特徵分群、比較不同群組行為差異的分析方法">cohort analysis&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>RFM 位在行為資料累積到一定量之後。它需要每個使用者的 session 歷史（計算 Recency 和 Frequency）和交易歷史（計算 Monetary）。免費產品可以用替代指標取代 Monetary — 產生的內容數量、邀請的使用者數、完成的關鍵操作數。RFM 的前提和 cohort analysis 相同：去識別化（&lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/redaction/" data-link-title="Redaction" data-link-desc="說明在事件資料離開 client 之前把敏感欄位的值替換成遮罩或移除的機制">redaction&lt;/a>）已完成。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>產品需要 RFM 的訊號是「需要對不同行為模式的使用者採取不同策略」。高 R 高 F 高 M 的忠實客戶需要維護關係，低 R 高 F 高 M 的潛在流失客戶需要挽留，高 R 低 F 低 M 的新使用者需要引導降低入門門檻。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>RFM 要定義每個維度的計算方式（Recency 用天數還是週數、Frequency 的時間窗口多長、Monetary 用什麼指標）、分位數（五等分還是三等分）、群組歸納（125 種 profile 歸納成幾個可操作群組）、以及重新計算的頻率（每週還是每月）。分群結果是動態的 — 使用者行為改變時群組會變。&lt;/p></description><content:encoded><![CDATA[<p>RFM 的核心概念是「用 Recency（最近活躍度）、Frequency（使用頻率）、Monetary（貢獻價值）三個維度把使用者分成可操作的群組」。每個維度獨立評分後組合，識別出忠實客戶、潛在流失、新使用者、休眠使用者等群組。可先對照 <a href="/blog/monitoring/knowledge-cards/cohort-analysis/" data-link-title="Cohort Analysis" data-link-desc="說明把使用者按共同特徵分群、比較不同群組行為差異的分析方法">cohort analysis</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>RFM 位在行為資料累積到一定量之後。它需要每個使用者的 session 歷史（計算 Recency 和 Frequency）和交易歷史（計算 Monetary）。免費產品可以用替代指標取代 Monetary — 產生的內容數量、邀請的使用者數、完成的關鍵操作數。RFM 的前提和 cohort analysis 相同：去識別化（<a href="/blog/monitoring/knowledge-cards/redaction/" data-link-title="Redaction" data-link-desc="說明在事件資料離開 client 之前把敏感欄位的值替換成遮罩或移除的機制">redaction</a>）已完成。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>產品需要 RFM 的訊號是「需要對不同行為模式的使用者採取不同策略」。高 R 高 F 高 M 的忠實客戶需要維護關係，低 R 高 F 高 M 的潛在流失客戶需要挽留，高 R 低 F 低 M 的新使用者需要引導降低入門門檻。</p>
<h2 id="設計責任">設計責任</h2>
<p>RFM 要定義每個維度的計算方式（Recency 用天數還是週數、Frequency 的時間窗口多長、Monetary 用什麼指標）、分位數（五等分還是三等分）、群組歸納（125 種 profile 歸納成幾個可操作群組）、以及重新計算的頻率（每週還是每月）。分群結果是動態的 — 使用者行為改變時群組會變。</p>
]]></content:encoded></item><item><title>Rule engine 設計</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/rule-engine/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/rule-engine/</guid><description>&lt;p>Rule engine 是 collector 的主動處理層。事件寫入儲存後，rule engine 檢查事件是否匹配預定義的規則，匹配時執行對應的動作。沒有 rule engine 的 collector 是被動的資料倉庫 — 開發者需要主動查詢才能發現問題。Rule engine 讓 collector 能在問題發生時主動通知。&lt;/p>
&lt;h2 id="三段式規則結構">三段式規則結構&lt;/h2>
&lt;p>每條規則由三部分組成：條件（什麼事件觸發）、動作（觸發後做什麼）、模板（動作的內容格式）。&lt;/p>
&lt;h3 id="條件">條件&lt;/h3>
&lt;p>條件定義「哪些事件匹配這條規則」。條件是事件欄位的過濾器 — 事件類型、事件名稱、屬性值的比較。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;condition&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;terminal.connect.*&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;severity&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;fatal&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>條件支援的匹配方式：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>精確匹配&lt;/strong>：&lt;code>&amp;quot;type&amp;quot;: &amp;quot;error&amp;quot;&lt;/code> — 事件類型必須是 error&lt;/li>
&lt;li>&lt;strong>前綴匹配&lt;/strong>：&lt;code>&amp;quot;name&amp;quot;: &amp;quot;terminal.connect.*&amp;quot;&lt;/code> — 事件名稱以 &lt;code>terminal.connect.&lt;/code> 開頭&lt;/li>
&lt;li>&lt;strong>數值比較&lt;/strong>：&lt;code>&amp;quot;data.duration_ms&amp;quot;: { &amp;quot;gt&amp;quot;: 5000 }&lt;/code> — 持續時間超過 5 秒&lt;/li>
&lt;li>&lt;strong>組合條件&lt;/strong>：多個欄位條件同時滿足（AND 邏輯）&lt;/li>
&lt;/ul>
&lt;h3 id="動作">動作&lt;/h3>
&lt;p>動作定義「條件匹配後做什麼」。常見的動作類型：&lt;/p>
&lt;p>&lt;strong>通知&lt;/strong>：發送訊息到指定管道（email、Slack webhook、Telegram bot、桌面通知）。&lt;/p>
&lt;p>&lt;strong>寫 summary&lt;/strong>：把匹配的事件摘要寫入 summary 檔案，供定期 review。和逐筆事件不同，summary 是聚合後的結果（例如「過去一小時有 15 個 terminal.connect.failed」）。&lt;/p>
&lt;p>&lt;strong>觸發 webhook&lt;/strong>：向外部 URL 發送 HTTP POST，讓其他系統可以接收事件並做進一步處理。&lt;/p>
&lt;p>&lt;strong>執行腳本&lt;/strong>：在 collector server 上執行預定義的 shell script。適合自動化回應（重啟服務、清理暫存檔、輪替 log）。執行腳本的安全風險需要控制 — 只允許白名單內的腳本。&lt;/p>
&lt;h3 id="模板">模板&lt;/h3>
&lt;p>模板定義動作的內容格式。通知的訊息內容、webhook 的 request body — 用模板語法（Go template 或 mustache）把事件欄位填入。&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">{{ .name }} 發生於 {{ .ts }}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">嚴重度：{{ .data.severity }}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">訊息：{{ .data.message }}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>模板讓同一個動作類型適用不同的事件 — 不需要為每種事件寫不同的通知函式。&lt;/p>
&lt;h2 id="規則評估時機">規則評估時機&lt;/h2>
&lt;h3 id="即時評估">即時評估&lt;/h3>
&lt;p>每個事件寫入後立即評估所有規則。適合需要即時回應的規則（fatal error 通知）。&lt;/p>
&lt;p>即時評估的成本和規則數量成正比 — 100 條規則代表每個事件寫入後做 100 次條件匹配。規則數量在數十條以內時，評估時間可以忽略。&lt;/p>
&lt;h3 id="批次評估">批次評估&lt;/h3>
&lt;p>定期（每分鐘、每小時）掃描一段時間內的事件，評估聚合類規則。適合基於統計的規則（「過去 5 分鐘 error 數量超過 10」「過去 1 小時某 endpoint 的 P95 回應時間超過 2 秒」）。&lt;/p>
&lt;p>批次評估需要時間窗口的概念 — 規則條件中包含時間範圍和聚合函式（count、avg、max、percentile）。&lt;/p>
&lt;h3 id="混合策略">混合策略&lt;/h3>
&lt;p>即時評估用於單一事件觸發的規則（fatal error → 立即通知），批次評估用於聚合觸發的規則（error rate 異常 → 定期檢查）。兩者可以共存。&lt;/p>
&lt;h2 id="規則管理">規則管理&lt;/h2>
&lt;p>規則以 JSON 或 YAML 檔案儲存在 collector 的設定目錄中。新增、修改、刪除規則是編輯檔案 + 重新載入 collector（signal 或 API call）。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nt">rules&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">fatal-error-notify&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">condition&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">error&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">data.severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">fatal&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">slack&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">webhook&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https://hooks.slack.com/...&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;FATAL: {{ .name }} at {{ .ts }}&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>規則檔案版本控制在 git 中，和 collector 的其他設定一起管理。規則變更歷史可追溯。&lt;/p>
&lt;h2 id="shell-執行的安全邊界">Shell 執行的安全邊界&lt;/h2>
&lt;p>Rule engine 的「執行腳本」動作在 collector 主機上執行 shell command。這個能力和 collector 的認證狀態組合後產生不同的風險等級。&lt;/p></description><content:encoded><![CDATA[<p>Rule engine 是 collector 的主動處理層。事件寫入儲存後，rule engine 檢查事件是否匹配預定義的規則，匹配時執行對應的動作。沒有 rule engine 的 collector 是被動的資料倉庫 — 開發者需要主動查詢才能發現問題。Rule engine 讓 collector 能在問題發生時主動通知。</p>
<h2 id="三段式規則結構">三段式規則結構</h2>
<p>每條規則由三部分組成：條件（什麼事件觸發）、動作（觸發後做什麼）、模板（動作的內容格式）。</p>
<h3 id="條件">條件</h3>
<p>條件定義「哪些事件匹配這條規則」。條件是事件欄位的過濾器 — 事件類型、事件名稱、屬性值的比較。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;condition&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;error&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;terminal.connect.*&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nt">&#34;severity&#34;</span><span class="p">:</span> <span class="s2">&#34;fatal&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>條件支援的匹配方式：</p>
<ul>
<li><strong>精確匹配</strong>：<code>&quot;type&quot;: &quot;error&quot;</code> — 事件類型必須是 error</li>
<li><strong>前綴匹配</strong>：<code>&quot;name&quot;: &quot;terminal.connect.*&quot;</code> — 事件名稱以 <code>terminal.connect.</code> 開頭</li>
<li><strong>數值比較</strong>：<code>&quot;data.duration_ms&quot;: { &quot;gt&quot;: 5000 }</code> — 持續時間超過 5 秒</li>
<li><strong>組合條件</strong>：多個欄位條件同時滿足（AND 邏輯）</li>
</ul>
<h3 id="動作">動作</h3>
<p>動作定義「條件匹配後做什麼」。常見的動作類型：</p>
<p><strong>通知</strong>：發送訊息到指定管道（email、Slack webhook、Telegram bot、桌面通知）。</p>
<p><strong>寫 summary</strong>：把匹配的事件摘要寫入 summary 檔案，供定期 review。和逐筆事件不同，summary 是聚合後的結果（例如「過去一小時有 15 個 terminal.connect.failed」）。</p>
<p><strong>觸發 webhook</strong>：向外部 URL 發送 HTTP POST，讓其他系統可以接收事件並做進一步處理。</p>
<p><strong>執行腳本</strong>：在 collector server 上執行預定義的 shell script。適合自動化回應（重啟服務、清理暫存檔、輪替 log）。執行腳本的安全風險需要控制 — 只允許白名單內的腳本。</p>
<h3 id="模板">模板</h3>
<p>模板定義動作的內容格式。通知的訊息內容、webhook 的 request body — 用模板語法（Go template 或 mustache）把事件欄位填入。</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">{{ .name }} 發生於 {{ .ts }}
</span></span><span class="line"><span class="ln">2</span><span class="cl">嚴重度：{{ .data.severity }}
</span></span><span class="line"><span class="ln">3</span><span class="cl">訊息：{{ .data.message }}</span></span></code></pre></div><p>模板讓同一個動作類型適用不同的事件 — 不需要為每種事件寫不同的通知函式。</p>
<h2 id="規則評估時機">規則評估時機</h2>
<h3 id="即時評估">即時評估</h3>
<p>每個事件寫入後立即評估所有規則。適合需要即時回應的規則（fatal error 通知）。</p>
<p>即時評估的成本和規則數量成正比 — 100 條規則代表每個事件寫入後做 100 次條件匹配。規則數量在數十條以內時，評估時間可以忽略。</p>
<h3 id="批次評估">批次評估</h3>
<p>定期（每分鐘、每小時）掃描一段時間內的事件，評估聚合類規則。適合基於統計的規則（「過去 5 分鐘 error 數量超過 10」「過去 1 小時某 endpoint 的 P95 回應時間超過 2 秒」）。</p>
<p>批次評估需要時間窗口的概念 — 規則條件中包含時間範圍和聚合函式（count、avg、max、percentile）。</p>
<h3 id="混合策略">混合策略</h3>
<p>即時評估用於單一事件觸發的規則（fatal error → 立即通知），批次評估用於聚合觸發的規則（error rate 異常 → 定期檢查）。兩者可以共存。</p>
<h2 id="規則管理">規則管理</h2>
<p>規則以 JSON 或 YAML 檔案儲存在 collector 的設定目錄中。新增、修改、刪除規則是編輯檔案 + 重新載入 collector（signal 或 API call）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">rules</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="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">fatal-error-notify</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">    </span><span class="nt">condition</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="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">error</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">      </span><span class="nt">data.severity</span><span class="p">:</span><span class="w"> </span><span class="l">fatal</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">    </span><span class="nt">action</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="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">slack</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w">      </span><span class="nt">webhook</span><span class="p">:</span><span class="w"> </span><span class="l">https://hooks.slack.com/...</span><span class="w">
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="w">      </span><span class="nt">template</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;FATAL: {{ .name }} at {{ .ts }}&#34;</span></span></span></code></pre></div><p>規則檔案版本控制在 git 中，和 collector 的其他設定一起管理。規則變更歷史可追溯。</p>
<h2 id="shell-執行的安全邊界">Shell 執行的安全邊界</h2>
<p>Rule engine 的「執行腳本」動作在 collector 主機上執行 shell command。這個能力和 collector 的認證狀態組合後產生不同的風險等級。</p>
<h3 id="攻擊鏈">攻擊鏈</h3>
<p>無認證模式下，攻擊者可以向 collector 的 <code>/v1/events</code> endpoint 注入偽造事件。如果偽造事件匹配了一條規則、且規則的動作是執行 free-form shell command，攻擊者等於取得了 collector 主機的命令執行權（RCE — Remote Code Execution）。</p>
<p>攻擊路徑：注入假事件 → 匹配 rule → 執行 shell → RCE。</p>
<h3 id="防護措施">防護措施</h3>
<p><strong>Rule 定義不可透過 API 新增</strong>。Rule 只能由管理員透過配置檔或 CLI 設定，collector 的 HTTP API 不提供 rule CRUD endpoint。攻擊者即使能注入事件也無法新增 rule — 但現有 rule 的條件如果太寬（例如 <code>type: error</code> 沒有進一步限定 name），偽造的 error 事件仍可能匹配。</p>
<p><strong>Shell command 使用 allowlist</strong>。Rule 的 action 指定 command name（如 <code>restart-ttyd</code>），command 的實際路徑在配置檔的 allowlist 中定義。Rule 不接受 free-form shell string（如 <code>sh -c &quot;rm -rf /&quot;</code>）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># 配置檔</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">allowed_commands</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="nt">restart-ttyd</span><span class="p">:</span><span class="w"> </span><span class="l">/usr/local/bin/restart-ttyd.sh</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="nt">notify-slack</span><span class="p">:</span><span class="w"> </span><span class="l">/usr/local/bin/notify-slack.sh</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="nt">rules</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="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">fatal-error-response</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="nt">condition</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="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">error</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span><span class="nt">data.severity</span><span class="p">:</span><span class="w"> </span><span class="l">fatal</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">    </span><span class="nt">action</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">      </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">command</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">      </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">restart-ttyd </span><span class="w"> </span><span class="c"># 只接受 allowlist 中的 name</span></span></span></code></pre></div><p><strong>無認證模式下的額外限制</strong>。Collector 無認證時（同區網信任），建議禁用 command 類型的動作、只允許通知和 webhook。認證啟用後才解鎖 command 動作 — 認證確保只有授權的 SDK 實例能送事件，降低偽造事件觸發 rule 的風險。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<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>規模成長後的演進路徑 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a></li>
<li>事件的分類和命名 → <a href="/blog/monitoring/01-mental-model/four-event-types/" data-link-title="四類事件的完整定義" data-link-desc="Event / Error / Metric / Lifecycle 四類事件各自的語意、觸發時機和典型用途 — 分類是監控體系的統一語言">監控心智模型 四類事件</a></li>
<li>Rule engine 在偽造流量偵測的應用 → <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>
</ul>
]]></content:encoded></item><item><title>去識別化策略</title><link>https://tarrragon.github.io/blog/monitoring/07-security-privacy/anonymization-strategy/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/07-security-privacy/anonymization-strategy/</guid><description>&lt;p>去識別化是把監控資料中可以關聯到特定個人的欄位，轉換成無法回溯到個人但仍保留分析價值的形式。去識別化和 &lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/redaction/" data-link-title="Redaction" data-link-desc="說明在事件資料離開 client 之前把敏感欄位的值替換成遮罩或移除的機制">redaction&lt;/a> 的差別在於：redaction 完全移除資訊（&lt;code>[REDACTED]&lt;/code>），去識別化保留結構化的資訊但移除可識別性。&lt;/p>
&lt;h2 id="ip-截斷">IP 截斷&lt;/h2>
&lt;p>IP 位址是最常見的個人識別欄位。完整的 IPv4 位址（&lt;code>192.168.1.50&lt;/code>）可以定位到特定的網路和裝置；截斷後的 IP（&lt;code>192.168.1.0&lt;/code>）保留網段資訊但無法定位到特定裝置。&lt;/p>
&lt;h3 id="截斷策略">截斷策略&lt;/h3>
&lt;p>&lt;strong>IPv4 末八位清零&lt;/strong>：&lt;code>192.168.1.50&lt;/code> → &lt;code>192.168.1.0&lt;/code>。保留 /24 網段資訊，足以判斷「使用者在哪個網段」但無法定位到特定裝置。Google Analytics 採用這個策略。&lt;/p>
&lt;p>&lt;strong>IPv4 末十六位清零&lt;/strong>：&lt;code>192.168.1.50&lt;/code> → &lt;code>192.168.0.0&lt;/code>。更強的去識別化，但地理定位精度降低到城市級。&lt;/p>
&lt;p>&lt;strong>IPv6&lt;/strong>：截斷更多位元。IPv6 的後 80 位通常包含 MAC 位址衍生的 interface ID — 截斷到 /48 前綴保留 ISP 資訊，移除裝置識別。&lt;/p>
&lt;h3 id="實作位置">實作位置&lt;/h3>
&lt;p>IP 截斷應在 collector 收到事件後、寫入儲存前執行。SDK 端不做 IP 截斷 — SDK 通常不知道自己的外部 IP（知道的是 NAT 後的內部 IP），外部 IP 是 collector 從 HTTP request 的 source IP 取得的。&lt;/p>
&lt;h2 id="user-agent-簡化">User Agent 簡化&lt;/h2>
&lt;p>User agent 字串包含瀏覽器版本、OS 版本、裝置型號 — 組合起來可能形成唯一的 fingerprint。簡化 user agent 保留有用的分類資訊（「iOS 17 上的 Safari」），移除可用於 fingerprinting 的細節（「iPhone 15 Pro Max, Build/22A3354」）。&lt;/p>
&lt;h3 id="簡化規則">簡化規則&lt;/h3>
&lt;p>保留：平台（iOS / Android / Windows / macOS）、主要版本號（iOS 17、Android 14）、瀏覽器類型（Safari / Chrome / Firefox）。&lt;/p>
&lt;p>移除：minor version、build number、裝置型號、CPU 架構、語言設定。&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">原始：Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">簡化：iOS/17 Safari&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="stack-trace-路徑清理">Stack Trace 路徑清理&lt;/h2>
&lt;p>Error 事件的 stack trace 包含檔案路徑。檔案路徑可能洩漏部署結構（&lt;code>/home/deploy_user/app/v2.3.1/src/...&lt;/code>）或開發者的個人資訊（&lt;code>/Users/alice/projects/...&lt;/code>）。&lt;/p>
&lt;h3 id="清理規則">清理規則&lt;/h3>
&lt;p>&lt;strong>移除使用者目錄前綴&lt;/strong>：&lt;code>/Users/alice/projects/app/src/main.dart:42&lt;/code> → &lt;code>src/main.dart:42&lt;/code>。保留 source file 相對路徑和行號，移除使用者名稱。&lt;/p>
&lt;p>&lt;strong>移除部署路徑前綴&lt;/strong>：&lt;code>/opt/deploy/releases/20260619/app/lib/...&lt;/code> → &lt;code>lib/...&lt;/code>。保留程式碼結構，移除部署細節。&lt;/p>
&lt;p>&lt;strong>統一 path separator&lt;/strong>：Windows 路徑（&lt;code>C:\Users\...&lt;/code>）和 Unix 路徑（&lt;code>/home/...&lt;/code>）統一處理。&lt;/p>
&lt;p>清理規則用正則表達式匹配常見的路徑前綴模式，替換為空字串。自訂的部署路徑格式需要在 collector 設定中額外註冊。&lt;/p>
&lt;h2 id="session-uuid">Session UUID&lt;/h2>
&lt;p>Session ID 用於關聯同一次使用中的多個事件。UUID v4（隨機產生）作為 session ID，沒有可預測性、沒有順序性、無法回推使用者身份。&lt;/p>
&lt;h3 id="session-id-的生命週期">Session ID 的生命週期&lt;/h3>
&lt;p>SDK 在初始化時產生一個 UUID v4 作為 session ID，所有事件附帶這個 ID。App 重新啟動時產生新的 session ID — 前後兩次使用的事件無法關聯。&lt;/p>
&lt;p>這個設計讓分析粒度限制在「一次使用」而非「一個使用者」。如果需要跨 session 關聯（例如計算 DAU），需要另一個 persistent ID — 但 persistent ID 本身就是可識別資訊，需要使用者同意。&lt;/p>
&lt;h3 id="避免使用可識別的-id">避免使用可識別的 ID&lt;/h3>
&lt;p>裝置 ID（IDFA / GAID）、安裝 ID、使用者帳號 — 這些可以關聯到特定個人，不適合作為監控系統的 session ID。使用 UUID v4 確保 session ID 的唯一性來自隨機性而非身份。&lt;/p>
&lt;p>去識別化是資料保護的一環，另一環是在資料離開 client 之前就處理 — &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/sdk-redaction-api/" data-link-title="SDK Redaction API 設計" data-link-desc="預設 redaction rule 過濾已知敏感欄位、自訂 pattern 擴展應用特有的 secret 格式 — redaction 在 SDK 端執行，敏感資料不離開 client">SDK Redaction API 設計&lt;/a>從 SDK 端攔截敏感欄位。法規層面的具體要求見 &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/gdpr-minimization/" data-link-title="GDPR 最小化原則的工程落地" data-link-desc="資料最小化、目的限制、儲存限制 — GDPR 三個核心原則在監控系統的工程實作方式">GDPR 最小化原則的工程落地&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;/p></description><content:encoded><![CDATA[<p>去識別化是把監控資料中可以關聯到特定個人的欄位，轉換成無法回溯到個人但仍保留分析價值的形式。去識別化和 <a href="/blog/monitoring/knowledge-cards/redaction/" data-link-title="Redaction" data-link-desc="說明在事件資料離開 client 之前把敏感欄位的值替換成遮罩或移除的機制">redaction</a> 的差別在於：redaction 完全移除資訊（<code>[REDACTED]</code>），去識別化保留結構化的資訊但移除可識別性。</p>
<h2 id="ip-截斷">IP 截斷</h2>
<p>IP 位址是最常見的個人識別欄位。完整的 IPv4 位址（<code>192.168.1.50</code>）可以定位到特定的網路和裝置；截斷後的 IP（<code>192.168.1.0</code>）保留網段資訊但無法定位到特定裝置。</p>
<h3 id="截斷策略">截斷策略</h3>
<p><strong>IPv4 末八位清零</strong>：<code>192.168.1.50</code> → <code>192.168.1.0</code>。保留 /24 網段資訊，足以判斷「使用者在哪個網段」但無法定位到特定裝置。Google Analytics 採用這個策略。</p>
<p><strong>IPv4 末十六位清零</strong>：<code>192.168.1.50</code> → <code>192.168.0.0</code>。更強的去識別化，但地理定位精度降低到城市級。</p>
<p><strong>IPv6</strong>：截斷更多位元。IPv6 的後 80 位通常包含 MAC 位址衍生的 interface ID — 截斷到 /48 前綴保留 ISP 資訊，移除裝置識別。</p>
<h3 id="實作位置">實作位置</h3>
<p>IP 截斷應在 collector 收到事件後、寫入儲存前執行。SDK 端不做 IP 截斷 — SDK 通常不知道自己的外部 IP（知道的是 NAT 後的內部 IP），外部 IP 是 collector 從 HTTP request 的 source IP 取得的。</p>
<h2 id="user-agent-簡化">User Agent 簡化</h2>
<p>User agent 字串包含瀏覽器版本、OS 版本、裝置型號 — 組合起來可能形成唯一的 fingerprint。簡化 user agent 保留有用的分類資訊（「iOS 17 上的 Safari」），移除可用於 fingerprinting 的細節（「iPhone 15 Pro Max, Build/22A3354」）。</p>
<h3 id="簡化規則">簡化規則</h3>
<p>保留：平台（iOS / Android / Windows / macOS）、主要版本號（iOS 17、Android 14）、瀏覽器類型（Safari / Chrome / Firefox）。</p>
<p>移除：minor version、build number、裝置型號、CPU 架構、語言設定。</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">原始：Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X)
</span></span><span class="line"><span class="ln">2</span><span class="cl">簡化：iOS/17 Safari</span></span></code></pre></div><h2 id="stack-trace-路徑清理">Stack Trace 路徑清理</h2>
<p>Error 事件的 stack trace 包含檔案路徑。檔案路徑可能洩漏部署結構（<code>/home/deploy_user/app/v2.3.1/src/...</code>）或開發者的個人資訊（<code>/Users/alice/projects/...</code>）。</p>
<h3 id="清理規則">清理規則</h3>
<p><strong>移除使用者目錄前綴</strong>：<code>/Users/alice/projects/app/src/main.dart:42</code> → <code>src/main.dart:42</code>。保留 source file 相對路徑和行號，移除使用者名稱。</p>
<p><strong>移除部署路徑前綴</strong>：<code>/opt/deploy/releases/20260619/app/lib/...</code> → <code>lib/...</code>。保留程式碼結構，移除部署細節。</p>
<p><strong>統一 path separator</strong>：Windows 路徑（<code>C:\Users\...</code>）和 Unix 路徑（<code>/home/...</code>）統一處理。</p>
<p>清理規則用正則表達式匹配常見的路徑前綴模式，替換為空字串。自訂的部署路徑格式需要在 collector 設定中額外註冊。</p>
<h2 id="session-uuid">Session UUID</h2>
<p>Session ID 用於關聯同一次使用中的多個事件。UUID v4（隨機產生）作為 session ID，沒有可預測性、沒有順序性、無法回推使用者身份。</p>
<h3 id="session-id-的生命週期">Session ID 的生命週期</h3>
<p>SDK 在初始化時產生一個 UUID v4 作為 session ID，所有事件附帶這個 ID。App 重新啟動時產生新的 session ID — 前後兩次使用的事件無法關聯。</p>
<p>這個設計讓分析粒度限制在「一次使用」而非「一個使用者」。如果需要跨 session 關聯（例如計算 DAU），需要另一個 persistent ID — 但 persistent ID 本身就是可識別資訊，需要使用者同意。</p>
<h3 id="避免使用可識別的-id">避免使用可識別的 ID</h3>
<p>裝置 ID（IDFA / GAID）、安裝 ID、使用者帳號 — 這些可以關聯到特定個人，不適合作為監控系統的 session ID。使用 UUID v4 確保 session ID 的唯一性來自隨機性而非身份。</p>
<p>去識別化是資料保護的一環，另一環是在資料離開 client 之前就處理 — <a href="/blog/monitoring/07-security-privacy/sdk-redaction-api/" data-link-title="SDK Redaction API 設計" data-link-desc="預設 redaction rule 過濾已知敏感欄位、自訂 pattern 擴展應用特有的 secret 格式 — redaction 在 SDK 端執行，敏感資料不離開 client">SDK Redaction API 設計</a>從 SDK 端攔截敏感欄位。法規層面的具體要求見 <a href="/blog/monitoring/07-security-privacy/gdpr-minimization/" data-link-title="GDPR 最小化原則的工程落地" data-link-desc="資料最小化、目的限制、儲存限制 — GDPR 三個核心原則在監控系統的工程實作方式">GDPR 最小化原則的工程落地</a>。去識別化完成後的資料才能用於<a href="/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">行為分析</a> — 這是商業利用的入場條件。</p>
]]></content:encoded></item><item><title>從需求推導「該收集哪些事件」</title><link>https://tarrragon.github.io/blog/monitoring/01-mental-model/derive-collection-from-requirements/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/01-mental-model/derive-collection-from-requirements/</guid><description>&lt;p>事件收集策略的起點是需求，而非技術能力。「能收集什麼」取決於 SDK 和 collector 的實作；「該收集什麼」取決於誰需要這些資料、用來做什麼決策。從需求推導收集策略，避免兩個極端：什麼都收（儲存成本高、隱私風險大、真正重要的事件淹沒在噪音中）和什麼都不收（問題發生時沒有資料可查）。&lt;/p>
&lt;h2 id="四個需求方向">四個需求方向&lt;/h2>
&lt;h3 id="debug-需求問題發生時能定位根因">Debug 需求：問題發生時能定位根因&lt;/h3>
&lt;p>Debug 需求驅動的事件收集目標是「問題發生時，開發者能從事件記錄中重建問題的 context」。&lt;/p>
&lt;p>需要的事件類型：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Error&lt;/strong>：例外、非預期狀態、API 錯誤回應。包含 stack trace、error code、觸發條件。&lt;/li>
&lt;li>&lt;strong>Lifecycle&lt;/strong>：問題發生時的系統狀態 — app 版本、OS 版本、網路狀態、前景/背景。&lt;/li>
&lt;li>&lt;strong>Event（最近操作）&lt;/strong>：問題發生前使用者做了哪些操作。不需要完整的操作歷史，最近 10-20 個操作通常足夠。&lt;/li>
&lt;/ul>
&lt;p>推導方法：列出最近三個月遇到的 debug 困難場景，問「如果當時有哪些事件記錄，debug 時間能從 30 分鐘降到 5 分鐘？」。答案就是 debug 需求驅動的事件清單。&lt;/p>
&lt;p>app_tunnel（透過 WebSocket 連接遠端終端機的 Flutter app）的 T.C4 案例是典型的 debug 需求缺口 — 六個元件中四個零 log，debug 只能靠實機反覆測試。如果在企劃階段就設計了連線生命週期的五步 log，auth token 問題在第一次連線就能從 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;/p>
&lt;p>具體的事件表和查詢場景見 &lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/motivation-to-event-mapping/" data-link-title="動機驅動的事件設計" data-link-desc="Debug / 商業 / 資安 / 效能四個動機各自需要什麼事件 — 從「為什麼收」反推「收什麼」和「什麼階段啟用」">動機驅動的事件設計&lt;/a>。&lt;/p>
&lt;h3 id="行為分析需求使用者如何使用產品">行為分析需求：使用者如何使用產品&lt;/h3>
&lt;p>行為分析需求驅動的事件收集目標是「回答產品決策的問題」。&lt;/p>
&lt;p>需要的事件類型：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Event&lt;/strong>：使用者操作的完整記錄。需要足夠的粒度來回答「使用者在哪一步流失」（&lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="說明追蹤使用者在多步驟流程中每一步的轉換率和流失率的分析方法">funnel&lt;/a>）和「不同使用者群體的行為差異」（&lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/cohort-analysis/" data-link-title="Cohort Analysis" data-link-desc="說明把使用者按共同特徵分群、比較不同群組行為差異的分析方法">cohort&lt;/a>）。&lt;/li>
&lt;li>&lt;strong>Lifecycle&lt;/strong>：session 的開始和結束，用於計算使用時長和 session 頻率。&lt;/li>
&lt;/ul>
&lt;p>推導方法：列出產品團隊最常問的 3-5 個問題（「新功能有多少人用」「註冊流程在哪一步流失最多」「付費使用者和免費使用者的行為差異」），為每個問題列出需要的事件。&lt;/p>
&lt;p>自用工具通常沒有行為分析需求 — 使用者就是開發者本人。這個方向的事件可以跳過。&lt;/p>
&lt;p>具體的事件表和查詢場景見 &lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/motivation-to-event-mapping/" data-link-title="動機驅動的事件設計" data-link-desc="Debug / 商業 / 資安 / 效能四個動機各自需要什麼事件 — 從「為什麼收」反推「收什麼」和「什麼階段啟用」">動機驅動的事件設計&lt;/a>。&lt;/p>
&lt;h3 id="效能需求系統是否在可接受的範圍內運作">效能需求：系統是否在可接受的範圍內運作&lt;/h3>
&lt;p>效能需求驅動的事件收集目標是「發現效能退化和容量瓶頸」。&lt;/p>
&lt;p>需要的事件類型：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Metric&lt;/strong>：回應時間、frame rate、記憶體使用量、佇列長度。定期取樣或事件觸發。&lt;/li>
&lt;/ul>
&lt;p>推導方法：列出使用者會感知到的效能指標（頁面載入時間、動畫流暢度、操作回應延遲），為每個指標定義可接受的範圍和取樣頻率。&lt;/p>
&lt;p>具體的事件表和查詢場景見 &lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/motivation-to-event-mapping/" data-link-title="動機驅動的事件設計" data-link-desc="Debug / 商業 / 資安 / 效能四個動機各自需要什麼事件 — 從「為什麼收」反推「收什麼」和「什麼階段啟用」">動機驅動的事件設計&lt;/a>。&lt;/p>
&lt;h3 id="合規需求法規要求收集或禁止收集什麼">合規需求：法規要求收集或禁止收集什麼&lt;/h3>
&lt;p>合規需求同時驅動「必須收集」和「禁止收集」。&lt;/p>
&lt;p>必須收集：access log（誰在什麼時間存取了什麼資料）、audit trail（誰修改了什麼設定）。&lt;/p>
&lt;p>禁止收集：未經同意的個人識別資訊、兒童資料（COPPA）、健康資料（HIPAA）。&lt;/p>
&lt;p>推導方法：確認適用的法規（GDPR、CCPA、個資法），列出法規要求的最小收集項目和禁止項目。&lt;/p>
&lt;p>具體的事件表和查詢場景見 &lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/motivation-to-event-mapping/" data-link-title="動機驅動的事件設計" data-link-desc="Debug / 商業 / 資安 / 效能四個動機各自需要什麼事件 — 從「為什麼收」反推「收什麼」和「什麼階段啟用」">動機驅動的事件設計&lt;/a>。&lt;/p>
&lt;h2 id="從需求到事件清單的步驟">從需求到事件清單的步驟&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>列出需求方向&lt;/strong>：Debug / 行為分析 / 效能 / 合規，每個方向的消費者是誰（開發者 / 產品團隊 / 維運 / 法務）。&lt;/li>
&lt;li>&lt;strong>每個方向列出問題&lt;/strong>：消費者最常需要回答的 3-5 個問題。&lt;/li>
&lt;li>&lt;strong>每個問題列出需要的事件&lt;/strong>：回答這個問題需要哪些事件類型和哪些屬性。&lt;/li>
&lt;li>&lt;strong>去重和分類&lt;/strong>：不同方向可能需要同一個事件（error 事件同時服務 debug 和效能監控）。去重後按四類事件分類。&lt;/li>
&lt;li>&lt;strong>排優先順序&lt;/strong>：按「缺少這個事件的損失」排序。Debug 需求的 error 事件通常是最高優先。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>四類事件的定義 → &lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/four-event-types/" data-link-title="四類事件的完整定義" data-link-desc="Event / Error / Metric / Lifecycle 四類事件各自的語意、觸發時機和典型用途 — 分類是監控體系的統一語言">四類事件的完整定義&lt;/a>&lt;/li>
&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/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計&lt;/a>&lt;/li>
&lt;li>四個方向展開到具體事件名稱級 → &lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/motivation-to-event-mapping/" data-link-title="動機驅動的事件設計" data-link-desc="Debug / 商業 / 資安 / 效能四個動機各自需要什麼事件 — 從「為什麼收」反推「收什麼」和「什麼階段啟用」">動機驅動的事件設計&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>事件收集策略的起點是需求，而非技術能力。「能收集什麼」取決於 SDK 和 collector 的實作；「該收集什麼」取決於誰需要這些資料、用來做什麼決策。從需求推導收集策略，避免兩個極端：什麼都收（儲存成本高、隱私風險大、真正重要的事件淹沒在噪音中）和什麼都不收（問題發生時沒有資料可查）。</p>
<h2 id="四個需求方向">四個需求方向</h2>
<h3 id="debug-需求問題發生時能定位根因">Debug 需求：問題發生時能定位根因</h3>
<p>Debug 需求驅動的事件收集目標是「問題發生時，開發者能從事件記錄中重建問題的 context」。</p>
<p>需要的事件類型：</p>
<ul>
<li><strong>Error</strong>：例外、非預期狀態、API 錯誤回應。包含 stack trace、error code、觸發條件。</li>
<li><strong>Lifecycle</strong>：問題發生時的系統狀態 — app 版本、OS 版本、網路狀態、前景/背景。</li>
<li><strong>Event（最近操作）</strong>：問題發生前使用者做了哪些操作。不需要完整的操作歷史，最近 10-20 個操作通常足夠。</li>
</ul>
<p>推導方法：列出最近三個月遇到的 debug 困難場景，問「如果當時有哪些事件記錄，debug 時間能從 30 分鐘降到 5 分鐘？」。答案就是 debug 需求驅動的事件清單。</p>
<p>app_tunnel（透過 WebSocket 連接遠端終端機的 Flutter app）的 T.C4 案例是典型的 debug 需求缺口 — 六個元件中四個零 log，debug 只能靠實機反覆測試。如果在企劃階段就設計了連線生命週期的五步 log，auth token 問題在第一次連線就能從 log 定位（<a href="/blog/testing/02-client-observability/" data-link-title="模組二：客戶端可觀測性" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — log 設計是功能規格的一部分">testing 模組二</a>）。</p>
<p>具體的事件表和查詢場景見 <a href="/blog/monitoring/01-mental-model/motivation-to-event-mapping/" data-link-title="動機驅動的事件設計" data-link-desc="Debug / 商業 / 資安 / 效能四個動機各自需要什麼事件 — 從「為什麼收」反推「收什麼」和「什麼階段啟用」">動機驅動的事件設計</a>。</p>
<h3 id="行為分析需求使用者如何使用產品">行為分析需求：使用者如何使用產品</h3>
<p>行為分析需求驅動的事件收集目標是「回答產品決策的問題」。</p>
<p>需要的事件類型：</p>
<ul>
<li><strong>Event</strong>：使用者操作的完整記錄。需要足夠的粒度來回答「使用者在哪一步流失」（<a href="/blog/monitoring/knowledge-cards/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="說明追蹤使用者在多步驟流程中每一步的轉換率和流失率的分析方法">funnel</a>）和「不同使用者群體的行為差異」（<a href="/blog/monitoring/knowledge-cards/cohort-analysis/" data-link-title="Cohort Analysis" data-link-desc="說明把使用者按共同特徵分群、比較不同群組行為差異的分析方法">cohort</a>）。</li>
<li><strong>Lifecycle</strong>：session 的開始和結束，用於計算使用時長和 session 頻率。</li>
</ul>
<p>推導方法：列出產品團隊最常問的 3-5 個問題（「新功能有多少人用」「註冊流程在哪一步流失最多」「付費使用者和免費使用者的行為差異」），為每個問題列出需要的事件。</p>
<p>自用工具通常沒有行為分析需求 — 使用者就是開發者本人。這個方向的事件可以跳過。</p>
<p>具體的事件表和查詢場景見 <a href="/blog/monitoring/01-mental-model/motivation-to-event-mapping/" data-link-title="動機驅動的事件設計" data-link-desc="Debug / 商業 / 資安 / 效能四個動機各自需要什麼事件 — 從「為什麼收」反推「收什麼」和「什麼階段啟用」">動機驅動的事件設計</a>。</p>
<h3 id="效能需求系統是否在可接受的範圍內運作">效能需求：系統是否在可接受的範圍內運作</h3>
<p>效能需求驅動的事件收集目標是「發現效能退化和容量瓶頸」。</p>
<p>需要的事件類型：</p>
<ul>
<li><strong>Metric</strong>：回應時間、frame rate、記憶體使用量、佇列長度。定期取樣或事件觸發。</li>
</ul>
<p>推導方法：列出使用者會感知到的效能指標（頁面載入時間、動畫流暢度、操作回應延遲），為每個指標定義可接受的範圍和取樣頻率。</p>
<p>具體的事件表和查詢場景見 <a href="/blog/monitoring/01-mental-model/motivation-to-event-mapping/" data-link-title="動機驅動的事件設計" data-link-desc="Debug / 商業 / 資安 / 效能四個動機各自需要什麼事件 — 從「為什麼收」反推「收什麼」和「什麼階段啟用」">動機驅動的事件設計</a>。</p>
<h3 id="合規需求法規要求收集或禁止收集什麼">合規需求：法規要求收集或禁止收集什麼</h3>
<p>合規需求同時驅動「必須收集」和「禁止收集」。</p>
<p>必須收集：access log（誰在什麼時間存取了什麼資料）、audit trail（誰修改了什麼設定）。</p>
<p>禁止收集：未經同意的個人識別資訊、兒童資料（COPPA）、健康資料（HIPAA）。</p>
<p>推導方法：確認適用的法規（GDPR、CCPA、個資法），列出法規要求的最小收集項目和禁止項目。</p>
<p>具體的事件表和查詢場景見 <a href="/blog/monitoring/01-mental-model/motivation-to-event-mapping/" data-link-title="動機驅動的事件設計" data-link-desc="Debug / 商業 / 資安 / 效能四個動機各自需要什麼事件 — 從「為什麼收」反推「收什麼」和「什麼階段啟用」">動機驅動的事件設計</a>。</p>
<h2 id="從需求到事件清單的步驟">從需求到事件清單的步驟</h2>
<ol>
<li><strong>列出需求方向</strong>：Debug / 行為分析 / 效能 / 合規，每個方向的消費者是誰（開發者 / 產品團隊 / 維運 / 法務）。</li>
<li><strong>每個方向列出問題</strong>：消費者最常需要回答的 3-5 個問題。</li>
<li><strong>每個問題列出需要的事件</strong>：回答這個問題需要哪些事件類型和哪些屬性。</li>
<li><strong>去重和分類</strong>：不同方向可能需要同一個事件（error 事件同時服務 debug 和效能監控）。去重後按四類事件分類。</li>
<li><strong>排優先順序</strong>：按「缺少這個事件的損失」排序。Debug 需求的 error 事件通常是最高優先。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>四類事件的定義 → <a href="/blog/monitoring/01-mental-model/four-event-types/" data-link-title="四類事件的完整定義" data-link-desc="Event / Error / Metric / Lifecycle 四類事件各自的語意、觸發時機和典型用途 — 分類是監控體系的統一語言">四類事件的完整定義</a></li>
<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/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計</a></li>
<li>四個方向展開到具體事件名稱級 → <a href="/blog/monitoring/01-mental-model/motivation-to-event-mapping/" data-link-title="動機驅動的事件設計" data-link-desc="Debug / 商業 / 資安 / 效能四個動機各自需要什麼事件 — 從「為什麼收」反推「收什麼」和「什麼階段啟用」">動機驅動的事件設計</a></li>
</ul>
]]></content:encoded></item><item><title>跟 OpenTelemetry 的 schema 差異對照</title><link>https://tarrragon.github.io/blog/monitoring/02-log-schema/otel-comparison/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/02-log-schema/otel-comparison/</guid><description>&lt;p>OpenTelemetry（OTLP）是 server-side 可觀測性的業界標準，定義了 traces、metrics、logs 三種 signal 的資料格式和傳輸協定。自架的 event schema 和 OTLP 在設計目標、複雜度和適用場景上有明確差異。&lt;/p>
&lt;h2 id="設計目標差異">設計目標差異&lt;/h2>
&lt;h3 id="otlp">OTLP&lt;/h3>
&lt;p>OTLP 的設計目標是「跨語言、跨框架、跨 vendor 的統一可觀測性標準」。它支援分散式追蹤（trace context propagation）、多維度 metric（histogram、summary、exponential histogram）、結構化 log。&lt;/p>
&lt;p>OTLP 的資料模型假設 server-side 的基礎設施：collector（如 OTel Collector）做資料路由和轉換，backend（如 Jaeger、Prometheus、Grafana）做儲存和視覺化。&lt;/p>
&lt;h3 id="自架-event-schema">自架 event schema&lt;/h3>
&lt;p>自架 schema 的設計目標是「client-side 監控的最小可用結構」。它假設的基礎設施是一個 HTTP endpoint + JSONL 檔案 + grep。不需要分散式追蹤（client 端通常是單一服務），不需要多維度 metric（counter 和 gauge 用 event 的 data 欄位表示即可）。&lt;/p>
&lt;h2 id="具體差異">具體差異&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>OTLP&lt;/th>
 &lt;th>自架 event schema&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Signal 類型&lt;/td>
 &lt;td>Trace / Metric / Log 三種獨立 signal&lt;/td>
 &lt;td>統一的 event 格式 + type 欄位&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>傳輸格式&lt;/td>
 &lt;td>Protobuf（HTTP/gRPC）&lt;/td>
 &lt;td>JSON（HTTP POST）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Trace context&lt;/td>
 &lt;td>SpanID / TraceID / ParentSpanID&lt;/td>
 &lt;td>Session ID（無分散式追蹤）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Metric 模型&lt;/td>
 &lt;td>Sum / Gauge / Histogram / Summary&lt;/td>
 &lt;td>data 欄位中的數值&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Resource&lt;/td>
 &lt;td>結構化的 resource attributes&lt;/td>
 &lt;td>source 欄位&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Schema 複雜度&lt;/td>
 &lt;td>高（完整的 Protobuf 定義）&lt;/td>
 &lt;td>低（JSON Schema，核心 6 欄位）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="自架-schema-簡化了什麼">自架 schema 簡化了什麼&lt;/h2>
&lt;h3 id="不做分散式追蹤">不做分散式追蹤&lt;/h3>
&lt;p>OTLP 的 trace signal 用 TraceID 和 SpanID 把跨服務的請求關聯起來。Client-side 監控通常不需要這個能力 — app 是單一服務，不存在跨服務的請求鏈路。&lt;/p>
&lt;p>自架 schema 用 session ID 關聯同一次使用中的事件，滿足「使用者在這次操作中做了什麼」的分析需求。&lt;/p>
&lt;h3 id="不用-protobuf">不用 Protobuf&lt;/h3>
&lt;p>OTLP 用 Protobuf 編碼資料，效率高（binary 格式、schema 驗證在編譯期）。但 Protobuf 需要 schema 檔案（.proto）、程式碼生成、和 SDK 語言的 Protobuf 套件。&lt;/p>
&lt;p>自架 schema 用 JSON，人類可讀、grep 友好、不需要額外工具。JSON 的效率比 Protobuf 低（文字格式、體積較大），但在 client-side 監控的事件量下（每分鐘數十到數百筆），效率差異不構成瓶頸。&lt;/p>
&lt;h3 id="簡化-metric-模型">簡化 metric 模型&lt;/h3>
&lt;p>OTLP 的 metric signal 支援 histogram（分桶分佈）、summary（百分位）、exponential histogram（自適應分桶）。這些模型在 server-side 的高頻度 metric 收集中有意義。&lt;/p>
&lt;p>自架 schema 把 metric 記錄為 event 的 data 欄位中的數值（&lt;code>{&amp;quot;type&amp;quot;: &amp;quot;metric&amp;quot;, &amp;quot;name&amp;quot;: &amp;quot;connect.duration&amp;quot;, &amp;quot;data&amp;quot;: {&amp;quot;value_ms&amp;quot;: 320}}&lt;/code>）。統計分析在 collector 端用查詢完成，不在 schema 層做聚合。&lt;/p>
&lt;h2 id="什麼時候切換到-otlp">什麼時候切換到 OTLP&lt;/h2>
&lt;p>以下訊號出現時，自架 schema 的簡化可能成為限制：&lt;/p></description><content:encoded><![CDATA[<p>OpenTelemetry（OTLP）是 server-side 可觀測性的業界標準，定義了 traces、metrics、logs 三種 signal 的資料格式和傳輸協定。自架的 event schema 和 OTLP 在設計目標、複雜度和適用場景上有明確差異。</p>
<h2 id="設計目標差異">設計目標差異</h2>
<h3 id="otlp">OTLP</h3>
<p>OTLP 的設計目標是「跨語言、跨框架、跨 vendor 的統一可觀測性標準」。它支援分散式追蹤（trace context propagation）、多維度 metric（histogram、summary、exponential histogram）、結構化 log。</p>
<p>OTLP 的資料模型假設 server-side 的基礎設施：collector（如 OTel Collector）做資料路由和轉換，backend（如 Jaeger、Prometheus、Grafana）做儲存和視覺化。</p>
<h3 id="自架-event-schema">自架 event schema</h3>
<p>自架 schema 的設計目標是「client-side 監控的最小可用結構」。它假設的基礎設施是一個 HTTP endpoint + JSONL 檔案 + grep。不需要分散式追蹤（client 端通常是單一服務），不需要多維度 metric（counter 和 gauge 用 event 的 data 欄位表示即可）。</p>
<h2 id="具體差異">具體差異</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>OTLP</th>
          <th>自架 event schema</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Signal 類型</td>
          <td>Trace / Metric / Log 三種獨立 signal</td>
          <td>統一的 event 格式 + type 欄位</td>
      </tr>
      <tr>
          <td>傳輸格式</td>
          <td>Protobuf（HTTP/gRPC）</td>
          <td>JSON（HTTP POST）</td>
      </tr>
      <tr>
          <td>Trace context</td>
          <td>SpanID / TraceID / ParentSpanID</td>
          <td>Session ID（無分散式追蹤）</td>
      </tr>
      <tr>
          <td>Metric 模型</td>
          <td>Sum / Gauge / Histogram / Summary</td>
          <td>data 欄位中的數值</td>
      </tr>
      <tr>
          <td>Resource</td>
          <td>結構化的 resource attributes</td>
          <td>source 欄位</td>
      </tr>
      <tr>
          <td>Schema 複雜度</td>
          <td>高（完整的 Protobuf 定義）</td>
          <td>低（JSON Schema，核心 6 欄位）</td>
      </tr>
  </tbody>
</table>
<h2 id="自架-schema-簡化了什麼">自架 schema 簡化了什麼</h2>
<h3 id="不做分散式追蹤">不做分散式追蹤</h3>
<p>OTLP 的 trace signal 用 TraceID 和 SpanID 把跨服務的請求關聯起來。Client-side 監控通常不需要這個能力 — app 是單一服務，不存在跨服務的請求鏈路。</p>
<p>自架 schema 用 session ID 關聯同一次使用中的事件，滿足「使用者在這次操作中做了什麼」的分析需求。</p>
<h3 id="不用-protobuf">不用 Protobuf</h3>
<p>OTLP 用 Protobuf 編碼資料，效率高（binary 格式、schema 驗證在編譯期）。但 Protobuf 需要 schema 檔案（.proto）、程式碼生成、和 SDK 語言的 Protobuf 套件。</p>
<p>自架 schema 用 JSON，人類可讀、grep 友好、不需要額外工具。JSON 的效率比 Protobuf 低（文字格式、體積較大），但在 client-side 監控的事件量下（每分鐘數十到數百筆），效率差異不構成瓶頸。</p>
<h3 id="簡化-metric-模型">簡化 metric 模型</h3>
<p>OTLP 的 metric signal 支援 histogram（分桶分佈）、summary（百分位）、exponential histogram（自適應分桶）。這些模型在 server-side 的高頻度 metric 收集中有意義。</p>
<p>自架 schema 把 metric 記錄為 event 的 data 欄位中的數值（<code>{&quot;type&quot;: &quot;metric&quot;, &quot;name&quot;: &quot;connect.duration&quot;, &quot;data&quot;: {&quot;value_ms&quot;: 320}}</code>）。統計分析在 collector 端用查詢完成，不在 schema 層做聚合。</p>
<h2 id="什麼時候切換到-otlp">什麼時候切換到 OTLP</h2>
<p>以下訊號出現時，自架 schema 的簡化可能成為限制：</p>
<p><strong>需要和 server-side 追蹤關聯</strong>：Client 端的操作要關聯到 server 端的 trace（「使用者點擊按鈕到 database query 的完整路徑」）。需要 OTLP 的 trace context propagation。</p>
<p><strong>事件量超過 JSONL 的處理能力</strong>：每秒數千筆事件時，JSON 的解析和 JSONL 的 grep 查詢成為瓶頸。OTLP + OTel Collector + 時間序列 DB 的管線能處理更高的吞吐量。</p>
<p><strong>需要接入多個 backend</strong>：同時送資料到 Prometheus（metric）、Jaeger（trace）、Elasticsearch（log）。OTel Collector 原生支援多 backend 路由，自架方案需要自己實作。</p>
<p>切換策略：SDK 層的 API 不變（init / event / error / metric），只改底層的傳輸和編碼。從 JSON POST 改成 OTLP export，SDK 的使用者不需要改程式碼。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>自架 schema 的完整定義 → <a href="/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">event.schema.json 完整欄位解說</a></li>
<li>Server-side 的可觀測性 → <a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">backend 04 可觀測性</a></li>
<li>Collector 的設計 → <a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計</a></li>
</ul>
]]></content:encoded></item><item><title>模組四：Collector 設計</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/</guid><description>&lt;p>回答「收到的事件怎麼處理」。挑戰在 collector 端，不在 SDK 端。&lt;/p>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Collector 架構（HTTP endpoint → JSON Schema 驗證 → 儲存 → 查詢 → rule engine）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> JSONL 匯出與備份格式（匯出格式、gzip 壓縮、備份保留）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 查詢 API 設計（CLI grep 友好 vs HTTP 查詢 endpoint）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Rule engine 設計（條件 → 動作 → 模板）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 規模演進：可插拔 Storage Backend（SQLite 預設 / PostgreSQL 觸發）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 功能分層與 Backend 選擇（SQLite 層 vs PostgreSQL 層的功能邊界）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> SQLite Backend 效能基準（寫入吞吐 / 查詢延遲 / 資源消耗的量化預期）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Ingestion Scaling（四層防線 — SDK 取樣 → Collector 背壓 → 水平擴展 → Queue 解耦）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 查詢消費模式（Debug / Alerting / 產品決策 / 安全審計 / 效能監控）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> DevOps Dashboard 設計&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Developer Dashboard 設計&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 中台 Dashboard 設計&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Container 部署設計（SQLite 在 container 中的 I/O 考量、volume mount、graceful shutdown）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 讀寫分離與查詢擴展（讀寫競爭辨識、Read Replica、預聚合、CQRS 判讀訊號）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 端到端資料完整性（資料損失地圖、完整性指標、被自己 SDK DDoS 的防護）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Error Fingerprint 與去重分群（fingerprint 演算法、message normalization、error_groups 表）&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">backend 01 資料庫&lt;/a>：PostgreSQL backend 的資料庫設計、&lt;a href="https://tarrragon.github.io/blog/backend/01-database/state-ownership-query-boundary/" data-link-title="1.8 State Ownership 與 Query Boundary" data-link-desc="正式狀態 vs 派生狀態的責任分層、CQRS / event sourcing / materialized view、四種 query 邊界">State Ownership 與 Query Boundary&lt;/a>&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/observability-query-design/" data-link-title="4.23 觀測查詢設計" data-link-desc="把觀測資料的讀取路徑當系統設計問題處理：三種查詢模式、storage tiering、pre-aggregation 與資源治理">backend 04 觀測查詢設計&lt;/a>：觀測領域的讀取路徑設計、CQRS 特化應用&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">backend 09 效能容量&lt;/a>：高併發寫入 / 大資料查詢的效能挑戰&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">DevOps 流量管控&lt;/a>：背壓、rate limit、熔斷的基礎概念&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/devops/07-burst-traffic/" data-link-title="模組七：突發流量應對" data-link-desc="行銷活動或新聞曝光帶來 10x-100x 流量時怎麼撐 — 突發分類、降級策略、queue 緩衝、規模分級應對">DevOps 突發流量&lt;/a>：突發流量分類、降級策略、queue 緩衝&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-monitoring/" data-link-title="斷網環境的監控與可觀測性" data-link-desc="Self-hosted 監控（Prometheus &amp;#43; Grafana）、離線 log 收集（Loki / ELK）、不能 phone home 的告警、NTP 時間同步">斷網環境的監控&lt;/a>：Collector 在斷網環境的部署方式——endpoint 改指 self-hosted backend、SDK 的 offline buffer 更重要&lt;/li>
&lt;li>實作 repo：tarrragon/monitor 的 collector/ + docs/challenges/（撞牆記錄）&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「收到的事件怎麼處理」。挑戰在 collector 端，不在 SDK 端。</p>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> Collector 架構（HTTP endpoint → JSON Schema 驗證 → 儲存 → 查詢 → rule engine）</li>
<li><input checked="" disabled="" type="checkbox"> JSONL 匯出與備份格式（匯出格式、gzip 壓縮、備份保留）</li>
<li><input checked="" disabled="" type="checkbox"> 查詢 API 設計（CLI grep 友好 vs HTTP 查詢 endpoint）</li>
<li><input checked="" disabled="" type="checkbox"> Rule engine 設計（條件 → 動作 → 模板）</li>
<li><input checked="" disabled="" type="checkbox"> 規模演進：可插拔 Storage Backend（SQLite 預設 / PostgreSQL 觸發）</li>
<li><input checked="" disabled="" type="checkbox"> 功能分層與 Backend 選擇（SQLite 層 vs PostgreSQL 層的功能邊界）</li>
<li><input checked="" disabled="" type="checkbox"> SQLite Backend 效能基準（寫入吞吐 / 查詢延遲 / 資源消耗的量化預期）</li>
<li><input checked="" disabled="" type="checkbox"> Ingestion Scaling（四層防線 — SDK 取樣 → Collector 背壓 → 水平擴展 → Queue 解耦）</li>
<li><input checked="" disabled="" type="checkbox"> 查詢消費模式（Debug / Alerting / 產品決策 / 安全審計 / 效能監控）</li>
<li><input checked="" disabled="" type="checkbox"> DevOps Dashboard 設計</li>
<li><input checked="" disabled="" type="checkbox"> Developer Dashboard 設計</li>
<li><input checked="" disabled="" type="checkbox"> 中台 Dashboard 設計</li>
<li><input checked="" disabled="" type="checkbox"> Container 部署設計（SQLite 在 container 中的 I/O 考量、volume mount、graceful shutdown）</li>
<li><input checked="" disabled="" type="checkbox"> 讀寫分離與查詢擴展（讀寫競爭辨識、Read Replica、預聚合、CQRS 判讀訊號）</li>
<li><input checked="" disabled="" type="checkbox"> 端到端資料完整性（資料損失地圖、完整性指標、被自己 SDK DDoS 的防護）</li>
<li><input checked="" disabled="" type="checkbox"> Error Fingerprint 與去重分群（fingerprint 演算法、message normalization、error_groups 表）</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">backend 01 資料庫</a>：PostgreSQL backend 的資料庫設計、<a href="/blog/backend/01-database/state-ownership-query-boundary/" data-link-title="1.8 State Ownership 與 Query Boundary" data-link-desc="正式狀態 vs 派生狀態的責任分層、CQRS / event sourcing / materialized view、四種 query 邊界">State Ownership 與 Query Boundary</a></li>
<li>→ <a href="/blog/backend/04-observability/observability-query-design/" data-link-title="4.23 觀測查詢設計" data-link-desc="把觀測資料的讀取路徑當系統設計問題處理：三種查詢模式、storage tiering、pre-aggregation 與資源治理">backend 04 觀測查詢設計</a>：觀測領域的讀取路徑設計、CQRS 特化應用</li>
<li>→ <a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">backend 09 效能容量</a>：高併發寫入 / 大資料查詢的效能挑戰</li>
<li>→ <a href="/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">DevOps 流量管控</a>：背壓、rate limit、熔斷的基礎概念</li>
<li>→ <a href="/blog/devops/07-burst-traffic/" data-link-title="模組七：突發流量應對" data-link-desc="行銷活動或新聞曝光帶來 10x-100x 流量時怎麼撐 — 突發分類、降級策略、queue 緩衝、規模分級應對">DevOps 突發流量</a>：突發流量分類、降級策略、queue 緩衝</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-monitoring/" data-link-title="斷網環境的監控與可觀測性" data-link-desc="Self-hosted 監控（Prometheus &#43; Grafana）、離線 log 收集（Loki / ELK）、不能 phone home 的告警、NTP 時間同步">斷網環境的監控</a>：Collector 在斷網環境的部署方式——endpoint 改指 self-hosted backend、SDK 的 offline buffer 更重要</li>
<li>實作 repo：tarrragon/monitor 的 collector/ + docs/challenges/（撞牆記錄）</li>
</ul>
]]></content:encoded></item><item><title>離線 buffer 與重試</title><link>https://tarrragon.github.io/blog/monitoring/03-sdk-design/offline-buffer/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/03-sdk-design/offline-buffer/</guid><description>&lt;p>離線 buffer 處理的是「事件產生時網路不可用」的場景。記憶體 buffer 有容量上限，離線時間超過 buffer 容量時需要決策：丟棄舊事件、持久化到本地儲存、或兩者混合。每種策略有不同的複雜度和資料保留量的取捨。&lt;/p>
&lt;h2 id="三種策略">三種策略&lt;/h2>
&lt;h3 id="fifo-丟棄最簡單">FIFO 丟棄（最簡單）&lt;/h3>
&lt;p>Buffer 滿時丟棄最舊的事件，保留最新的。整個 buffer 在記憶體中，不做本地 persistence。&lt;/p>
&lt;p>優點：實作最簡單（array + 容量檢查），不需要檔案系統存取，不增加磁碟 I/O。&lt;/p>
&lt;p>代價：離線超過 buffer 容量時，較舊的事件永久遺失。如果離線 30 分鐘、buffer 容量 200 筆、事件產生速率每分鐘 10 筆，前 100 筆（前 10 分鐘）的事件被丟棄。&lt;/p>
&lt;p>適合場景：自用工具（離線場景少、遺失部分事件影響低）、SDK 初期版本（先用最簡單的策略上線）。&lt;/p>
&lt;h3 id="本地-persistence最完整">本地 persistence（最完整）&lt;/h3>
&lt;p>Buffer 滿時把事件寫入本地檔案（SQLite、JSONL 檔案、SharedPreferences / UserDefaults）。網路恢復後從本地檔案讀取並補發。&lt;/p>
&lt;p>優點：離線期間的事件不會遺失（在本地儲存容量內）。&lt;/p>
&lt;p>代價：實作複雜度高 — 需要處理檔案讀寫、並發存取（多執行緒安全）、本地儲存容量管理（磁碟空間上限）、補發時的去重（同一筆事件可能已在記憶體 buffer 中被 flush 過）。&lt;/p>
&lt;p>適合場景：商業產品（使用者在地鐵、電梯、飛航模式下使用）、離線時間長且事件不可遺失的需求。&lt;/p>
&lt;h3 id="混合策略">混合策略&lt;/h3>
&lt;p>記憶體 buffer 處理正常情況和短暫離線。離線超過記憶體 buffer 容量時，溢出的事件寫入本地檔案。網路恢復後先 flush 記憶體 buffer（最新事件），再補發本地檔案中的事件（較舊事件）。&lt;/p>
&lt;p>混合策略的實作複雜度介於兩者之間。本地檔案只在溢出時使用，正常情況下不產生磁碟 I/O。&lt;/p>
&lt;h2 id="恢復後補發">恢復後補發&lt;/h2>
&lt;p>網路恢復後補發離線期間累積的事件，需要處理三個問題：&lt;/p>
&lt;h3 id="補發順序">補發順序&lt;/h3>
&lt;p>離線事件按 timestamp 順序補發，保持事件的時間順序。Collector 端收到的事件 timestamp 可能比當前時間早數小時 — 這是正常的離線補發，collector 應該根據事件的 timestamp 處理，不依賴收到時間。&lt;/p>
&lt;h3 id="補發速率">補發速率&lt;/h3>
&lt;p>一次送出大量離線事件可能讓 collector 過載。分批補發（每批 50-100 筆，間隔 1-2 秒），讓 collector 有時間處理。&lt;/p>
&lt;h3 id="去重">去重&lt;/h3>
&lt;p>同一筆事件可能同時存在於記憶體 buffer 和本地檔案中（寫入本地檔案時 buffer 中也有一份）。Collector 端用事件的唯一識別（timestamp + session_id + name 的組合，或 SDK 產生的 event_id UUID）做去重。&lt;/p>
&lt;h2 id="本地儲存容量管理">本地儲存容量管理&lt;/h2>
&lt;p>本地 persistence 需要設定磁碟使用上限。上限取決於事件大小和保留時間。&lt;/p>
&lt;p>以平均每筆事件 500 bytes 估算：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>上限&lt;/th>
 &lt;th>可儲存事件數&lt;/th>
 &lt;th>備註&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1 MB&lt;/td>
 &lt;td>~2,000&lt;/td>
 &lt;td>約 3 小時（每分鐘 10 筆）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>10 MB&lt;/td>
 &lt;td>~20,000&lt;/td>
 &lt;td>約 33 小時&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>50 MB&lt;/td>
 &lt;td>~100,000&lt;/td>
 &lt;td>約 7 天&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>自用工具 1 MB 足夠（離線場景少）。行動 app 10-50 MB 合理（使用者可能整天離線）。超過上限時用 FIFO 丟棄最舊的本地檔案。&lt;/p>
&lt;h2 id="各平台的本地儲存路徑">各平台的本地儲存路徑&lt;/h2>
&lt;p>本地 persistence 的檔案路徑和格式因平台而異。MVP 階段全用記憶體 FIFO（最簡單策略），本地 persistence 標為第二階段。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>平台&lt;/th>
 &lt;th>建議路徑&lt;/th>
 &lt;th>檔案格式&lt;/th>
 &lt;th>備註&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Flutter&lt;/td>
 &lt;td>&lt;code>getApplicationSupportDirectory()&lt;/code>&lt;/td>
 &lt;td>JSONL&lt;/td>
 &lt;td>不會被 iCloud 備份（和 Documents 不同）、不會被系統自動清理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Python&lt;/td>
 &lt;td>&lt;code>~/.cache/monitor/&lt;/code> 或 &lt;code>platformdirs.user_cache_dir('monitor')&lt;/code>&lt;/td>
 &lt;td>JSONL&lt;/td>
 &lt;td>遵循 XDG 標準、&lt;code>platformdirs&lt;/code> 套件處理跨平台&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>JS/Web&lt;/td>
 &lt;td>&lt;code>localStorage&lt;/code> 或 &lt;code>IndexedDB&lt;/code>&lt;/td>
 &lt;td>JSON&lt;/td>
 &lt;td>localStorage 有 5MB 限制、IndexedDB 更大但 API 較複雜&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>App 被強制終止時（iOS 的 &lt;code>kill&lt;/code>、Android 的 process death），記憶體 buffer 中未 flush 的事件會遺失。Flutter 的 &lt;code>AppLifecycleState.detached&lt;/code> 不保證有時間執行 flush。接受這個遺失 — 強制終止是極端情境，下次啟動時 SDK 重新開始收集。&lt;/p></description><content:encoded><![CDATA[<p>離線 buffer 處理的是「事件產生時網路不可用」的場景。記憶體 buffer 有容量上限，離線時間超過 buffer 容量時需要決策：丟棄舊事件、持久化到本地儲存、或兩者混合。每種策略有不同的複雜度和資料保留量的取捨。</p>
<h2 id="三種策略">三種策略</h2>
<h3 id="fifo-丟棄最簡單">FIFO 丟棄（最簡單）</h3>
<p>Buffer 滿時丟棄最舊的事件，保留最新的。整個 buffer 在記憶體中，不做本地 persistence。</p>
<p>優點：實作最簡單（array + 容量檢查），不需要檔案系統存取，不增加磁碟 I/O。</p>
<p>代價：離線超過 buffer 容量時，較舊的事件永久遺失。如果離線 30 分鐘、buffer 容量 200 筆、事件產生速率每分鐘 10 筆，前 100 筆（前 10 分鐘）的事件被丟棄。</p>
<p>適合場景：自用工具（離線場景少、遺失部分事件影響低）、SDK 初期版本（先用最簡單的策略上線）。</p>
<h3 id="本地-persistence最完整">本地 persistence（最完整）</h3>
<p>Buffer 滿時把事件寫入本地檔案（SQLite、JSONL 檔案、SharedPreferences / UserDefaults）。網路恢復後從本地檔案讀取並補發。</p>
<p>優點：離線期間的事件不會遺失（在本地儲存容量內）。</p>
<p>代價：實作複雜度高 — 需要處理檔案讀寫、並發存取（多執行緒安全）、本地儲存容量管理（磁碟空間上限）、補發時的去重（同一筆事件可能已在記憶體 buffer 中被 flush 過）。</p>
<p>適合場景：商業產品（使用者在地鐵、電梯、飛航模式下使用）、離線時間長且事件不可遺失的需求。</p>
<h3 id="混合策略">混合策略</h3>
<p>記憶體 buffer 處理正常情況和短暫離線。離線超過記憶體 buffer 容量時，溢出的事件寫入本地檔案。網路恢復後先 flush 記憶體 buffer（最新事件），再補發本地檔案中的事件（較舊事件）。</p>
<p>混合策略的實作複雜度介於兩者之間。本地檔案只在溢出時使用，正常情況下不產生磁碟 I/O。</p>
<h2 id="恢復後補發">恢復後補發</h2>
<p>網路恢復後補發離線期間累積的事件，需要處理三個問題：</p>
<h3 id="補發順序">補發順序</h3>
<p>離線事件按 timestamp 順序補發，保持事件的時間順序。Collector 端收到的事件 timestamp 可能比當前時間早數小時 — 這是正常的離線補發，collector 應該根據事件的 timestamp 處理，不依賴收到時間。</p>
<h3 id="補發速率">補發速率</h3>
<p>一次送出大量離線事件可能讓 collector 過載。分批補發（每批 50-100 筆，間隔 1-2 秒），讓 collector 有時間處理。</p>
<h3 id="去重">去重</h3>
<p>同一筆事件可能同時存在於記憶體 buffer 和本地檔案中（寫入本地檔案時 buffer 中也有一份）。Collector 端用事件的唯一識別（timestamp + session_id + name 的組合，或 SDK 產生的 event_id UUID）做去重。</p>
<h2 id="本地儲存容量管理">本地儲存容量管理</h2>
<p>本地 persistence 需要設定磁碟使用上限。上限取決於事件大小和保留時間。</p>
<p>以平均每筆事件 500 bytes 估算：</p>
<table>
  <thead>
      <tr>
          <th>上限</th>
          <th>可儲存事件數</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1 MB</td>
          <td>~2,000</td>
          <td>約 3 小時（每分鐘 10 筆）</td>
      </tr>
      <tr>
          <td>10 MB</td>
          <td>~20,000</td>
          <td>約 33 小時</td>
      </tr>
      <tr>
          <td>50 MB</td>
          <td>~100,000</td>
          <td>約 7 天</td>
      </tr>
  </tbody>
</table>
<p>自用工具 1 MB 足夠（離線場景少）。行動 app 10-50 MB 合理（使用者可能整天離線）。超過上限時用 FIFO 丟棄最舊的本地檔案。</p>
<h2 id="各平台的本地儲存路徑">各平台的本地儲存路徑</h2>
<p>本地 persistence 的檔案路徑和格式因平台而異。MVP 階段全用記憶體 FIFO（最簡單策略），本地 persistence 標為第二階段。</p>
<table>
  <thead>
      <tr>
          <th>平台</th>
          <th>建議路徑</th>
          <th>檔案格式</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Flutter</td>
          <td><code>getApplicationSupportDirectory()</code></td>
          <td>JSONL</td>
          <td>不會被 iCloud 備份（和 Documents 不同）、不會被系統自動清理</td>
      </tr>
      <tr>
          <td>Python</td>
          <td><code>~/.cache/monitor/</code> 或 <code>platformdirs.user_cache_dir('monitor')</code></td>
          <td>JSONL</td>
          <td>遵循 XDG 標準、<code>platformdirs</code> 套件處理跨平台</td>
      </tr>
      <tr>
          <td>JS/Web</td>
          <td><code>localStorage</code> 或 <code>IndexedDB</code></td>
          <td>JSON</td>
          <td>localStorage 有 5MB 限制、IndexedDB 更大但 API 較複雜</td>
      </tr>
  </tbody>
</table>
<p>App 被強制終止時（iOS 的 <code>kill</code>、Android 的 process death），記憶體 buffer 中未 flush 的事件會遺失。Flutter 的 <code>AppLifecycleState.detached</code> 不保證有時間執行 flush。接受這個遺失 — 強制終止是極端情境，下次啟動時 SDK 重新開始收集。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>攢批送出策略 → <a href="/blog/monitoring/03-sdk-design/batch-flush/" data-link-title="攢批送出策略" data-link-desc="flush interval / buffer size / flush on close 三個控制點決定事件何時離開 SDK — 平衡即時性和網路效率">攢批送出策略</a></li>
<li>SDK 端的資料脫敏 → <a href="/blog/monitoring/03-sdk-design/redaction-helper/" data-link-title="SDK redaction helper" data-link-desc="在事件離開 SDK 前移除敏感資訊 — 預設 redaction rule 處理常見 pattern，自訂 rule 處理業務特定的 secret">SDK redaction helper</a></li>
<li>Collector 端如何處理補發事件 → <a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計</a></li>
<li>從 SDK 到 storage 的端到端資料損失地圖 → <a href="/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性</a></li>
</ul>
]]></content:encoded></item><item><title>服務掛了怎麼自動知道：從肉眼盯到主動告警</title><link>https://tarrragon.github.io/blog/linux/debug/service-failure-monitoring/</link><pubDate>Thu, 02 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/debug/service-failure-monitoring/</guid><description>&lt;p>服務掛了不需要用肉眼盯——systemd 本來就在追蹤每個 unit 的狀態，你要做的是把「讀權威狀態」這件事自動化，並在狀態變成失敗時主動推播給自己。這篇跟本系列其他篇的差別在時機：診斷是出事後回頭找根因，監控是讓系統在出事的當下就告訴你。兩者共用同一個地基——權威狀態。診斷是手動讀一次權威狀態，監控是訂閱權威狀態的變化、變壞就推播。&lt;/p>
&lt;p>理解這個框架後，監控就不是「裝一套很重的東西」，而是分層選擇：從 systemd 內建的失敗鉤子（不裝任何額外服務），到推播管道，到「整台機器死掉」的體外心跳，到完整的指標儀表板。多數人只需要前一兩層。&lt;/p>
&lt;h2 id="你現在手動在做的事要被取代的基線">你現在手動在做的事（要被取代的基線）&lt;/h2>
&lt;p>在自動化之前，先認清手動版本——這也是所有告警底層讀的同一個權威來源：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">systemctl --failed &lt;span class="c1"># 現在有哪些 unit 處於 failed（開機後系統怪怪的先掃這個）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">systemctl is-failed &amp;lt;unit&amp;gt; &lt;span class="c1"># 單一 unit 明確判失敗（比 is-active 直接）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">journalctl -u &amp;lt;unit&amp;gt; -f &lt;span class="c1"># 即時跟一個 unit 的 log&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>systemctl --failed&lt;/code> 就是「服務死活」的權威清單。手動版的問題不是不準，是你得記得去看。下面每一層都是把「記得去看」換成「壞了它來找你」。&lt;/p>
&lt;h2 id="第一層systemd-原生-onfailure-鉤子不裝額外服務">第一層：systemd 原生 &lt;code>OnFailure&lt;/code> 鉤子（不裝額外服務）&lt;/h2>
&lt;p>systemd 每個 unit 進入 failed 狀態時，可以自動觸發另一個 unit。這是最正統、零額外依賴的做法——告警邏輯就寫成一個普通的 systemd service。它由三塊組成：一個負責送通知的處理器 unit、一個實際送出的腳本、以及在你要監控的 unit 上掛一行 &lt;code>OnFailure=&lt;/code>。&lt;/p>
&lt;p>&lt;strong>通知處理器&lt;/strong>是一個 template unit（&lt;code>@&lt;/code> 表示可帶參數），參數 &lt;code>%i&lt;/code> 會是失敗的那個 unit 名：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ini" data-lang="ini">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># /etc/systemd/system/alert@.service&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="k">[Unit]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="na">Description&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">Alert on failure of %i&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="k">[Service]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="na">Type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">oneshot&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="na">ExecStart&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">/usr/local/bin/notify-failure %i&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>送出腳本&lt;/strong>負責把「哪個 unit、在哪台機、什麼時候」推出去。這裡有個實測踩到的坑：在 systemd service 的執行環境下，&lt;code>hostname&lt;/code> 指令可能回傳空字串，要改用 &lt;code>uname -n&lt;/code> 或讀 &lt;code>/etc/hostname&lt;/code> 才穩：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="cp">#!/bin/bash
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="cp">&lt;/span>&lt;span class="c1"># /usr/local/bin/notify-failure （記得 chmod +x）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="nv">unit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$1&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 只在「真正放棄」時告警：OnFailure 每次失敗都觸發（含 auto-restart 中途，見下節實測），&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1"># auto-restart 中途 ActiveState 是 activating、撞重試上限才進 failed。gate 掉中途避免洗告警。&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="nv">state&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>systemctl show &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$unit&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> -p ActiveState --value&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="o">[&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$state&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> failed &lt;span class="o">]&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nb">exit&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="nv">host&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>uname -n&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="c1"># 不要用 hostname，systemd 環境下可能回空&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="nv">ts&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>date -Is&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="nv">topic&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;你的私密topic&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">curl -fsS &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s2">&amp;#34;Title: &lt;/span>&lt;span class="nv">$host&lt;/span>&lt;span class="s2">: &lt;/span>&lt;span class="nv">$unit&lt;/span>&lt;span class="s2"> failed&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$unit&lt;/span>&lt;span class="s2"> 於 &lt;/span>&lt;span class="nv">$ts&lt;/span>&lt;span class="s2"> 進入 failed&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="s2">&amp;#34;https://ntfy.sh/&lt;/span>&lt;span class="nv">$topic&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>在要監控的 unit 掛上鉤子&lt;/strong>。針對單一 unit，加一行：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ini" data-lang="ini">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">[Unit]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="na">OnFailure&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">alert@%n.service # %n 是本 unit 的全名，會展開成 alert@&amp;lt;本unit&amp;gt;.service&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>要&lt;strong>一次套用到所有 service&lt;/strong>，用 top-level drop-in（放在 &lt;code>service.d/&lt;/code> 這個型別目錄下的設定會套用到每個 &lt;code>.service&lt;/code>）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ini" data-lang="ini">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># /etc/systemd/system/service.d/onfailure.conf&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="k">[Unit]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="na">OnFailure&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">alert@%n.service&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>改完 &lt;code>sudo systemctl daemon-reload&lt;/code>。&lt;strong>一個必須注意的遞迴陷阱&lt;/strong>：全域 drop-in 也會套到 &lt;code>alert@&lt;/code> 自己，它若失敗會觸發自己。給 &lt;code>alert@.service&lt;/code> 一個清空 &lt;code>OnFailure=&lt;/code> 的 override（&lt;code>[Unit]&lt;/code> 段寫 &lt;code>OnFailure=&lt;/code>）擋掉。&lt;/p>
&lt;p>這條鏈是實測驗證過的：故意讓一個 &lt;code>ExecStart=/bin/false&lt;/code> 的測試 service 失敗，systemd log 出現 &lt;code>Triggering OnFailure= dependencies&lt;/code>、&lt;code>alert@&lt;/code> 處理器被觸發跑完、&lt;code>curl&lt;/code> 推到 ntfy 回 HTTP 200——通知確實送出，全程沒有肉眼介入。&lt;/p>
&lt;h3 id="先自動重啟放棄了才吵你">先自動重啟、放棄了才吵你&lt;/h3>
&lt;p>多數暫時性失敗（一次連線抖動、一個 race）自己重試就好，不值得半夜叫醒你。把「自動復原」跟「告警」分兩段：讓 systemd 先重啟幾次，撐過重試上限才真的算放棄。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ini" data-lang="ini">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">[Service]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="na">Restart&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">on-failure&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="na">RestartSec&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">5&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="k">[Unit]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="na">StartLimitBurst&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">3 # 重試 3 次&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="na">StartLimitIntervalSec&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">60 # 60 秒內都失敗才進 failed（start-limit-hit）&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>這裡有個實測踩到、跟直覺相反的坑&lt;/strong>：&lt;code>OnFailure&lt;/code> 不是「放棄才觸發」，而是&lt;strong>每一次失敗都觸發&lt;/strong>——包含 &lt;code>Restart=on-failure&lt;/code> 的每次 auto-restart 中途。實測一個反覆 crash 的服務（重試 3 次後放棄）觸發了 &lt;strong>4 次&lt;/strong> &lt;code>OnFailure&lt;/code>（3 次 auto-restart + 1 次最終 &lt;code>start-limit-hit&lt;/code>）。所以只靠 &lt;code>Restart=&lt;/code> + &lt;code>StartLimit=&lt;/code> 這段 config，你會被每次瞬斷洗告警。&lt;/p></description><content:encoded><![CDATA[<p>服務掛了不需要用肉眼盯——systemd 本來就在追蹤每個 unit 的狀態，你要做的是把「讀權威狀態」這件事自動化，並在狀態變成失敗時主動推播給自己。這篇跟本系列其他篇的差別在時機：診斷是出事後回頭找根因，監控是讓系統在出事的當下就告訴你。兩者共用同一個地基——權威狀態。診斷是手動讀一次權威狀態，監控是訂閱權威狀態的變化、變壞就推播。</p>
<p>理解這個框架後，監控就不是「裝一套很重的東西」，而是分層選擇：從 systemd 內建的失敗鉤子（不裝任何額外服務），到推播管道，到「整台機器死掉」的體外心跳，到完整的指標儀表板。多數人只需要前一兩層。</p>
<h2 id="你現在手動在做的事要被取代的基線">你現在手動在做的事（要被取代的基線）</h2>
<p>在自動化之前，先認清手動版本——這也是所有告警底層讀的同一個權威來源：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">systemctl --failed          <span class="c1"># 現在有哪些 unit 處於 failed（開機後系統怪怪的先掃這個）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">systemctl is-failed &lt;unit&gt;  <span class="c1"># 單一 unit 明確判失敗（比 is-active 直接）</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">journalctl -u &lt;unit&gt; -f     <span class="c1"># 即時跟一個 unit 的 log</span></span></span></code></pre></div><p><code>systemctl --failed</code> 就是「服務死活」的權威清單。手動版的問題不是不準，是你得記得去看。下面每一層都是把「記得去看」換成「壞了它來找你」。</p>
<h2 id="第一層systemd-原生-onfailure-鉤子不裝額外服務">第一層：systemd 原生 <code>OnFailure</code> 鉤子（不裝額外服務）</h2>
<p>systemd 每個 unit 進入 failed 狀態時，可以自動觸發另一個 unit。這是最正統、零額外依賴的做法——告警邏輯就寫成一個普通的 systemd service。它由三塊組成：一個負責送通知的處理器 unit、一個實際送出的腳本、以及在你要監控的 unit 上掛一行 <code>OnFailure=</code>。</p>
<p><strong>通知處理器</strong>是一個 template unit（<code>@</code> 表示可帶參數），參數 <code>%i</code> 會是失敗的那個 unit 名：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># /etc/systemd/system/alert@.service</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">Alert on failure of %i</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">oneshot</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/local/bin/notify-failure %i</span></span></span></code></pre></div><p><strong>送出腳本</strong>負責把「哪個 unit、在哪台機、什麼時候」推出去。這裡有個實測踩到的坑：在 systemd service 的執行環境下，<code>hostname</code> 指令可能回傳空字串，要改用 <code>uname -n</code> 或讀 <code>/etc/hostname</code> 才穩：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="cp"></span><span class="c1"># /usr/local/bin/notify-failure   （記得 chmod +x）</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nv">unit</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 只在「真正放棄」時告警：OnFailure 每次失敗都觸發（含 auto-restart 中途，見下節實測），</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># auto-restart 中途 ActiveState 是 activating、撞重試上限才進 failed。gate 掉中途避免洗告警。</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="nv">state</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>systemctl show <span class="s2">&#34;</span><span class="nv">$unit</span><span class="s2">&#34;</span> -p ActiveState --value<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$state</span><span class="s2">&#34;</span> <span class="o">=</span> failed <span class="o">]</span> <span class="o">||</span> <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="nv">host</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>uname -n<span class="k">)</span><span class="s2">&#34;</span>                     <span class="c1"># 不要用 hostname，systemd 環境下可能回空</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="nv">ts</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>date -Is<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="nv">topic</span><span class="o">=</span><span class="s2">&#34;你的私密topic&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">curl -fsS <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  -H <span class="s2">&#34;Title: </span><span class="nv">$host</span><span class="s2">: </span><span class="nv">$unit</span><span class="s2"> failed&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="se"></span>  -d <span class="s2">&#34;</span><span class="nv">$unit</span><span class="s2"> 於 </span><span class="nv">$ts</span><span class="s2"> 進入 failed&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="se"></span>  <span class="s2">&#34;https://ntfy.sh/</span><span class="nv">$topic</span><span class="s2">&#34;</span></span></span></code></pre></div><p><strong>在要監控的 unit 掛上鉤子</strong>。針對單一 unit，加一行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">OnFailure</span><span class="o">=</span><span class="s">alert@%n.service    # %n 是本 unit 的全名，會展開成 alert@&lt;本unit&gt;.service</span></span></span></code></pre></div><p>要<strong>一次套用到所有 service</strong>，用 top-level drop-in（放在 <code>service.d/</code> 這個型別目錄下的設定會套用到每個 <code>.service</code>）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># /etc/systemd/system/service.d/onfailure.conf</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">OnFailure</span><span class="o">=</span><span class="s">alert@%n.service</span></span></span></code></pre></div><p>改完 <code>sudo systemctl daemon-reload</code>。<strong>一個必須注意的遞迴陷阱</strong>：全域 drop-in 也會套到 <code>alert@</code> 自己，它若失敗會觸發自己。給 <code>alert@.service</code> 一個清空 <code>OnFailure=</code> 的 override（<code>[Unit]</code> 段寫 <code>OnFailure=</code>）擋掉。</p>
<p>這條鏈是實測驗證過的：故意讓一個 <code>ExecStart=/bin/false</code> 的測試 service 失敗，systemd log 出現 <code>Triggering OnFailure= dependencies</code>、<code>alert@</code> 處理器被觸發跑完、<code>curl</code> 推到 ntfy 回 HTTP 200——通知確實送出，全程沒有肉眼介入。</p>
<h3 id="先自動重啟放棄了才吵你">先自動重啟、放棄了才吵你</h3>
<p>多數暫時性失敗（一次連線抖動、一個 race）自己重試就好，不值得半夜叫醒你。把「自動復原」跟「告警」分兩段：讓 systemd 先重啟幾次，撐過重試上限才真的算放棄。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">Restart</span><span class="o">=</span><span class="s">on-failure</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">RestartSec</span><span class="o">=</span><span class="s">5</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">StartLimitBurst</span><span class="o">=</span><span class="s">3          # 重試 3 次</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="na">StartLimitIntervalSec</span><span class="o">=</span><span class="s">60   # 60 秒內都失敗才進 failed（start-limit-hit）</span></span></span></code></pre></div><p><strong>這裡有個實測踩到、跟直覺相反的坑</strong>：<code>OnFailure</code> 不是「放棄才觸發」，而是<strong>每一次失敗都觸發</strong>——包含 <code>Restart=on-failure</code> 的每次 auto-restart 中途。實測一個反覆 crash 的服務（重試 3 次後放棄）觸發了 <strong>4 次</strong> <code>OnFailure</code>（3 次 auto-restart + 1 次最終 <code>start-limit-hit</code>）。所以只靠 <code>Restart=</code> + <code>StartLimit=</code> 這段 config，你會被每次瞬斷洗告警。</p>
<p>真正做到「只在放棄才吵」，靠的是上面送出腳本開頭那道 gate：<code>systemctl show &lt;unit&gt; -p ActiveState</code> 在 auto-restart 中途是 <code>activating</code>、撞上限進 failed 才是 <code>failed</code>，腳本只在 <code>failed</code> 才送。加上 gate 後同一個 crash 測試從 4 次告警降到 1 次（只剩最終放棄那次）。config 負責「重試幾次」，handler 的 gate 負責「只在終局告警」——兩段合起來才是完整的「先重啟、放棄才吵」。</p>
<h3 id="抓進程活著但沒在做事外部健康探針">抓「進程活著但沒在做事」：外部健康探針</h3>
<p><code>OnFailure</code> 抓的是「進程狀態變了」——crash、exit、被 kill。但服務可能<strong>進程還在、卻沒在做事</strong>：hung、deadlock、內部子系統壞掉。這種 systemd 看它還 <code>active</code>、不會觸發任何告警——正是<a href="../process-service-state-diagnosis/">「進程活著 ≠ 在運作」</a>那條，搬到監控場景。</p>
<p>要抓這種，得從外面<strong>主動戳它、看它回不回應</strong>：一個 timer 定時對服務發一個健康請求（HTTP 服務就 curl 它的 <code>/health</code>）並設逾時；戳不動、逾時失敗，就讓「那個檢查」自己 failed，一樣走 <code>OnFailure</code> 告警。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># health-check.service（oneshot）+ 一個每 2 分鐘跑的 .timer</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">oneshot</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/bin/curl -fsS --max-time 5 http://127.0.0.1:8899/health</span></span></span></code></pre></div><p>實測對照最清楚：讓一個健康服務卡在 <code>sleep</code>（進程還在、單執行緒不再回應），<code>systemctl is-active</code> 仍顯示 <code>active</code>——systemd 沒察覺；但這個外部探針 curl <code>/health</code> 5 秒逾時、check 失敗、告警發出。<strong>systemd 抓進程死、外部探針抓進程活著但 hung，兩層互補、缺一漏一種。</strong></p>
<h3 id="canary先證明告警管線本身是好的">canary：先證明告警管線本身是好的</h3>
<p>監控最怕的失效模式是「出事時才發現它早就不會叫了」。防這個的辦法是養一隻 <strong>canary</strong>——一個你可控的假服務，專門用來確認整條管線是活的。它一物兩用：</p>
<ul>
<li><strong>驗證管線</strong>：故意弄掛它，看「失敗 → OnFailure → 推送」真的一路通到你手機，不必拿 sshd 這種真服務去冒險。</li>
<li><strong>當活性訊號</strong>：它自己若無故失敗告警，等於告訴你告警系統本身還在運作。</li>
</ul>
<p>做法是一個極簡 HTTP 服務（stdlib 就夠、不必框架），留幾個測試入口：<code>/health</code> 正常回、<code>/crash</code> 故意退出（測 <code>OnFailure</code>）、<code>/hang</code> 進程活著但不回應（測外部探針）。這樣任何時候都能一鍵重驗監控沒有默默失效。</p>
<h2 id="第二層推去哪裡關鍵是能離開這台機器">第二層：推去哪裡（關鍵是能離開這台機器）</h2>
<p>處理器腳本裡那一段 <code>curl</code> 可以換成任何管道：</p>
<ul>
<li><strong>ntfy</strong>（<code>ntfy.sh</code> 或自架）：一行 <code>curl</code> 推到手機，最省事，上面的例子就是。它怎麼運作、公共站 vs 自架、以及「topic 名稱就是唯一的密碼」這個安全模型，見 <a href="../ntfy-push-notification-service/">ntfy：推送通知服務</a>。</li>
<li><strong>email</strong>：要先設好一個 MTA（如 <code>msmtp</code>），腳本改成 <code>mail</code> / <code>sendmail</code>。</li>
<li><strong>Telegram bot、Apprise</strong>（一個工具打多個目標）等。</li>
</ul>
<p>判準只有一條：<strong>告警要送到機器外</strong>。送桌面 <code>notify-send</code> 只有你正盯著螢幕時才有用；送手機或 email，離開座位、人在外面也收得到。一台跑正事的機器，告警管道應該落在它之外。</p>
<h2 id="第三層整台機器死掉怎麼辦監控自己的盲點">第三層：整台機器死掉怎麼辦（監控自己的盲點）</h2>
<p><code>OnFailure</code> 有個根本限制：<strong>它靠 systemd 觸發，機器整台掛了（當機、斷電、kernel panic），systemd 自己都沒了，發不出任何告警。</strong> 這是所有「機器自己監控自己」方案的共同盲點——它報得了服務的死，報不了自己這台的死。</p>
<p>覆蓋這一層要反過來做：讓機器定時對一個<strong>體外</strong>的服務「報平安」，平安訊號一停，由那個體外服務替你告警。這叫 dead-man&rsquo;s switch（心跳監控）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># /etc/systemd/system/heartbeat.service</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">oneshot</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">curl -fsS https://hc-ping.com/&lt;你的-uuid&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 搭配一個 heartbeat.timer，OnUnitActiveSec=5min 定時打</span></span></span></code></pre></div><p>心跳超過設定時間沒到，healthchecks.io（或自架的 Uptime Kuma）就通知你。<strong>體內的監控管不了自己這台的死亡，一定要有體外的一隻眼睛</strong>——這跟本系列 <a href="../machine-unreachable/">機器連不到或起不來</a> 是同一個問題的兩面：那篇是機器已經不回應時從外面怎麼查，心跳是讓「不回應」這件事本身自動觸發告警。</p>
<h2 id="第四層要指標趨勢門檻不只是-updown">第四層：要指標、趨勢、門檻（不只是 up/down）</h2>
<p>當你要的不只是「掛了沒」，而是 CPU、記憶體、磁碟、延遲的趨勢與門檻告警（例如磁碟用量超過 80% 就先警告，接上本系列反覆出現的「磁碟滿連鎖」），就進到完整監控堆疊：</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>定位</th>
          <th>什麼時候選它</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Netdata</td>
          <td>開箱即用、自帶大量預設告警</td>
          <td>單機、想要圖表 + 門檻告警、最不想設定</td>
      </tr>
      <tr>
          <td>Monit</td>
          <td>輕量、每服務健康檢查 + 自動動作</td>
          <td>要「掛了自動跑一段修復腳本」、超出 systemd <code>Restart=</code> 能表達的邏輯</td>
      </tr>
      <tr>
          <td>Prometheus + Alertmanager</td>
          <td>指標抓取 + 告警規則引擎</td>
          <td>多台機器、要歷史數據與可擴展的告警規則</td>
      </tr>
      <tr>
          <td>Uptime Kuma</td>
          <td>自架的 up/down + 心跳面板</td>
          <td>想要一個面板統一看多台/多服務、也能當第三層的心跳接收端</td>
      </tr>
  </tbody>
</table>
<p>這一層不是每個人都需要。單機、只想知道某個服務死活，第一層就夠；要看趨勢、跨機、設門檻，才值得付這層的設定與維運成本。</p>
<h2 id="先確認有沒有沒有就從最簡單開始">先確認有沒有，沒有就從最簡單開始</h2>
<p>監控最好在出事之前就建好，不是等第一次沒人發現的當機才想到。有兩個時機該主動確認這台機器有沒有在監控自己：<strong>裝好一台新機器時</strong>，跟<strong>發現自己反覆在除同一個服務的失敗時</strong>。確認的方式就是讀權威狀態：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">systemctl --failed                      <span class="c1"># 現在有沒有 failed 的</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">systemctl show sshd -p OnFailure        <span class="c1"># 關鍵服務有沒有掛告警鉤子</span></span></span></code></pre></div><p>沒有任何監控的話，<strong>從最簡單那層開始建，別一開始就上重的</strong>：第一層的 <code>OnFailure</code> + ntfy 就能讓「服務掛了」主動找上你，零額外 daemon、幾個檔案就設好。遠端機器至少把 sshd 掛上——它掛了你就失聯，是最該先監控的一個。等你真的需要趨勢圖、跨機、或告警內容不能經過第三方時，再往自架 ntfy（帳號 + ACL）跟完整監控堆疊爬。多數單機、個人用的情境，停在第一層就夠。</p>
<h2 id="依情境選">依情境選</h2>
<p>把上面四層對回你實際要監控的東西：</p>
<ul>
<li><strong>某個 service 掛了想被通知</strong> → 第一層 <code>OnFailure</code> drop-in + ntfy。不裝額外 daemon，最貼近 systemd。</li>
<li><strong>希望先自動重啟、救不回來才告警</strong> → 第一層再加 <code>Restart=on-failure</code> + <code>StartLimit*</code>。</li>
<li><strong>怕整台機器當掉沒人知道</strong> → 第三層心跳 / dead-man switch。這層體內方案覆蓋不到，必須體外。</li>
<li><strong>要看資源趨勢、跨多台、設門檻告警</strong> → 第四層，單機用 Netdata、多機用 Prometheus 堆疊。</li>
</ul>
<p>判準是先分清你要監控的層級：<strong>單一 service 的死活、整台機器的死活、還是資源的趨勢</strong>——三種對應不同層，別拿其中一種去蓋另一種。最常見的誤區是以為體內的 <code>OnFailure</code> 能報自己這台的當機，那正是它的盲點。</p>
<h2 id="下一步">下一步</h2>
<ul>
<li>告警把你叫來之後，怎麼判那個服務到底是什麼狀態（failed、restart loop、還是活著但子系統 wedged）→ <a href="../process-service-state-diagnosis/">程序、服務與狀態怎麼判</a>。</li>
<li>機器完全不回應、心跳斷掉之後從外面怎麼查 → <a href="../machine-unreachable/">機器連不到或起不來</a>。</li>
<li>底層那套「讀權威狀態、不靠肉眼猜」的判讀紀律 → <a href="../diagnosis-read-authoritative-state/">診斷心法</a>。</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>事件枚舉與補齊檢查</title><link>https://tarrragon.github.io/blog/monitoring/01-mental-model/event-enumeration-method/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/01-mental-model/event-enumeration-method/</guid><description>&lt;p>事件枚舉的目的是為一個服務建立完整的事件清單 — 每個事件有明確的類型、名稱、觸發時機和 data schema。枚舉的方法從操作盤點出發，經過四類補齊檢查，產出可以直接實作 SDK 埋點的事件表。&lt;/p>
&lt;h2 id="從操作盤點推導事件">從操作盤點推導事件&lt;/h2>
&lt;p>每個使用者操作（BDD 操作盤點的產物）至少對應一個 event 類型的事件。操作的失敗路徑對應 error 類型。操作涉及的效能測量對應 metric 類型。操作觸發的系統狀態轉換對應 lifecycle 類型。&lt;/p>
&lt;p>推導鏈：操作 → 四類事件候選 → 命名 → data schema。&lt;/p>
&lt;p>以一個透過 WebSocket 連接遠端終端機的 app 為例，「連線到終端機」這個操作推導出的事件：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>四類&lt;/th>
 &lt;th>事件名稱&lt;/th>
 &lt;th>觸發時機&lt;/th>
 &lt;th>data schema&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>event&lt;/td>
 &lt;td>terminal.connect.start&lt;/td>
 &lt;td>使用者點擊連線按鈕&lt;/td>
 &lt;td>&lt;code>{url, trigger: &amp;quot;manual&amp;quot; | &amp;quot;auto&amp;quot;}&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>event&lt;/td>
 &lt;td>terminal.connect.done&lt;/td>
 &lt;td>連線成功、開始接收 output&lt;/td>
 &lt;td>&lt;code>{url, duration_ms}&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>error&lt;/td>
 &lt;td>terminal.connect.failed&lt;/td>
 &lt;td>連線失敗（逾時、拒絕、認證失敗）&lt;/td>
 &lt;td>&lt;code>{url, error, step}&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>metric&lt;/td>
 &lt;td>terminal.connect.duration&lt;/td>
 &lt;td>連線完成（成功或失敗）&lt;/td>
 &lt;td>&lt;code>{duration_ms, success: bool}&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>lifecycle&lt;/td>
 &lt;td>ws.connected&lt;/td>
 &lt;td>WebSocket 連線狀態轉換&lt;/td>
 &lt;td>&lt;code>{url}&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>lifecycle&lt;/td>
 &lt;td>ws.disconnected&lt;/td>
 &lt;td>WebSocket 斷線&lt;/td>
 &lt;td>&lt;code>{url, reason, code}&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>一個操作推導出六個事件 — 因為這個操作跨越了使用者行為（event）、可能失敗（error）、有效能測量（metric）、涉及系統狀態轉換（lifecycle）四個面向。其中 &lt;code>connect.done&lt;/code> 和 &lt;code>connect.duration&lt;/code> 記錄的是同一事實的兩個面向（見下方邊界案例段），自用場景合併成 &lt;code>connect.done&lt;/code> 帶 &lt;code>duration_ms&lt;/code> 欄位更簡潔。&lt;/p>
&lt;h2 id="四類補齊檢查">四類補齊檢查&lt;/h2>
&lt;p>列完所有操作的事件後，對每個功能區域跑一次四類補齊檢查 — 逐列確認每一類是否都有對應的事件。&lt;/p>
&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>connect.start / connect.done&lt;/td>
 &lt;td>connect.failed&lt;/td>
 &lt;td>connect.duration&lt;/td>
 &lt;td>ws.connected / ws.disconnected&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>認證&lt;/td>
 &lt;td>auth.biometric.attempt&lt;/td>
 &lt;td>auth.biometric.failed&lt;/td>
 &lt;td>auth.duration&lt;/td>
 &lt;td>auth.state_changed&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>輸入&lt;/td>
 &lt;td>input.submit&lt;/td>
 &lt;td>input.parse_error&lt;/td>
 &lt;td>—&lt;/td>
 &lt;td>—&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>配對&lt;/td>
 &lt;td>enrollment.qr.scan / enrollment.done&lt;/td>
 &lt;td>enrollment.failed&lt;/td>
 &lt;td>enrollment.duration&lt;/td>
 &lt;td>—&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>空格是候選遺漏。每個空格問一個問題：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>event 空&lt;/strong>：「這個功能區域有使用者操作嗎？」有 → 補事件；沒有（純系統內部）→ 合理的空格&lt;/li>
&lt;li>&lt;strong>error 空&lt;/strong>：「這個功能區域能失敗嗎？」能 → 補事件；不能失敗的功能極少 → 再想一次&lt;/li>
&lt;li>&lt;strong>metric 空&lt;/strong>：「這個功能區域有值得量測的效能指標嗎？」有 → 補事件；操作瞬間完成且不涉及外部依賴 → 合理的空格&lt;/li>
&lt;li>&lt;strong>lifecycle 空&lt;/strong>：「這個功能區域涉及系統狀態轉換嗎？」有 → 補事件；純資料操作不改系統狀態 → 合理的空格&lt;/li>
&lt;/ul>
&lt;p>上表中「輸入」的 metric 和 lifecycle 空格是合理的 — 文字輸入送出不涉及效能量測和系統狀態轉換。「配對」的 lifecycle 空格也合理 — 配對完成後不改變系統的執行狀態。&lt;/p>
&lt;h2 id="粒度判準">粒度判準&lt;/h2>
&lt;p>事件粒度的判斷用一個 SRP 判準：&lt;strong>一個事件記一個事實&lt;/strong>。&lt;/p>
&lt;h3 id="拆分訊號">拆分訊號&lt;/h3>
&lt;p>一個事件記了兩個獨立的事實 → 拆成兩個事件。&lt;/p>
&lt;p>&lt;code>terminal.connect_and_auth&lt;/code> 同時記錄「連線建立」和「認證通過」。這兩個事實的失敗模式不同（連線失敗是網路問題、認證失敗是帳密問題）、觸發時機不同、消費者不同。拆成 &lt;code>terminal.connect.done&lt;/code> 和 &lt;code>auth.token.sent&lt;/code>。&lt;/p>
&lt;h3 id="合併訊號">合併訊號&lt;/h3>
&lt;p>兩個事件永遠同時觸發且消費者相同 → 合併成一個事件。&lt;/p>
&lt;p>&lt;code>terminal.input.keystroke&lt;/code> 和 &lt;code>terminal.input.keystroke_logged&lt;/code> 永遠同時觸發（每個按鍵一次），data schema 相同。合併成一個 &lt;code>terminal.input.keystroke&lt;/code>。&lt;/p>
&lt;h3 id="邊界案例">邊界案例&lt;/h3>
&lt;p>&lt;code>connect.done&lt;/code> 同時記 event 和 metric（成功事件 + duration）。這是一個事實（連線完成）的兩個面向，可以合併成一個事件帶 &lt;code>duration_ms&lt;/code> 欄位，也可以拆成 event 和 metric 兩筆。判斷依據是查詢需求 — 如果 funnel 分析和效能分析會分開查，拆開讓各自的查詢更簡單；如果都在同一個 dashboard 看，合併減少事件量。&lt;/p>
&lt;h2 id="data-schema-設計">data schema 設計&lt;/h2>
&lt;p>每個事件的 data 欄位回答「發生了什麼的 context」。設計原則：&lt;/p></description><content:encoded><![CDATA[<p>事件枚舉的目的是為一個服務建立完整的事件清單 — 每個事件有明確的類型、名稱、觸發時機和 data schema。枚舉的方法從操作盤點出發，經過四類補齊檢查，產出可以直接實作 SDK 埋點的事件表。</p>
<h2 id="從操作盤點推導事件">從操作盤點推導事件</h2>
<p>每個使用者操作（BDD 操作盤點的產物）至少對應一個 event 類型的事件。操作的失敗路徑對應 error 類型。操作涉及的效能測量對應 metric 類型。操作觸發的系統狀態轉換對應 lifecycle 類型。</p>
<p>推導鏈：操作 → 四類事件候選 → 命名 → data schema。</p>
<p>以一個透過 WebSocket 連接遠端終端機的 app 為例，「連線到終端機」這個操作推導出的事件：</p>
<table>
  <thead>
      <tr>
          <th>四類</th>
          <th>事件名稱</th>
          <th>觸發時機</th>
          <th>data schema</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>event</td>
          <td>terminal.connect.start</td>
          <td>使用者點擊連線按鈕</td>
          <td><code>{url, trigger: &quot;manual&quot; | &quot;auto&quot;}</code></td>
      </tr>
      <tr>
          <td>event</td>
          <td>terminal.connect.done</td>
          <td>連線成功、開始接收 output</td>
          <td><code>{url, duration_ms}</code></td>
      </tr>
      <tr>
          <td>error</td>
          <td>terminal.connect.failed</td>
          <td>連線失敗（逾時、拒絕、認證失敗）</td>
          <td><code>{url, error, step}</code></td>
      </tr>
      <tr>
          <td>metric</td>
          <td>terminal.connect.duration</td>
          <td>連線完成（成功或失敗）</td>
          <td><code>{duration_ms, success: bool}</code></td>
      </tr>
      <tr>
          <td>lifecycle</td>
          <td>ws.connected</td>
          <td>WebSocket 連線狀態轉換</td>
          <td><code>{url}</code></td>
      </tr>
      <tr>
          <td>lifecycle</td>
          <td>ws.disconnected</td>
          <td>WebSocket 斷線</td>
          <td><code>{url, reason, code}</code></td>
      </tr>
  </tbody>
</table>
<p>一個操作推導出六個事件 — 因為這個操作跨越了使用者行為（event）、可能失敗（error）、有效能測量（metric）、涉及系統狀態轉換（lifecycle）四個面向。其中 <code>connect.done</code> 和 <code>connect.duration</code> 記錄的是同一事實的兩個面向（見下方邊界案例段），自用場景合併成 <code>connect.done</code> 帶 <code>duration_ms</code> 欄位更簡潔。</p>
<h2 id="四類補齊檢查">四類補齊檢查</h2>
<p>列完所有操作的事件後，對每個功能區域跑一次四類補齊檢查 — 逐列確認每一類是否都有對應的事件。</p>
<table>
  <thead>
      <tr>
          <th>功能區域</th>
          <th>event</th>
          <th>error</th>
          <th>metric</th>
          <th>lifecycle</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>連線</td>
          <td>connect.start / connect.done</td>
          <td>connect.failed</td>
          <td>connect.duration</td>
          <td>ws.connected / ws.disconnected</td>
      </tr>
      <tr>
          <td>認證</td>
          <td>auth.biometric.attempt</td>
          <td>auth.biometric.failed</td>
          <td>auth.duration</td>
          <td>auth.state_changed</td>
      </tr>
      <tr>
          <td>輸入</td>
          <td>input.submit</td>
          <td>input.parse_error</td>
          <td>—</td>
          <td>—</td>
      </tr>
      <tr>
          <td>配對</td>
          <td>enrollment.qr.scan / enrollment.done</td>
          <td>enrollment.failed</td>
          <td>enrollment.duration</td>
          <td>—</td>
      </tr>
  </tbody>
</table>
<p>空格是候選遺漏。每個空格問一個問題：</p>
<ul>
<li><strong>event 空</strong>：「這個功能區域有使用者操作嗎？」有 → 補事件；沒有（純系統內部）→ 合理的空格</li>
<li><strong>error 空</strong>：「這個功能區域能失敗嗎？」能 → 補事件；不能失敗的功能極少 → 再想一次</li>
<li><strong>metric 空</strong>：「這個功能區域有值得量測的效能指標嗎？」有 → 補事件；操作瞬間完成且不涉及外部依賴 → 合理的空格</li>
<li><strong>lifecycle 空</strong>：「這個功能區域涉及系統狀態轉換嗎？」有 → 補事件；純資料操作不改系統狀態 → 合理的空格</li>
</ul>
<p>上表中「輸入」的 metric 和 lifecycle 空格是合理的 — 文字輸入送出不涉及效能量測和系統狀態轉換。「配對」的 lifecycle 空格也合理 — 配對完成後不改變系統的執行狀態。</p>
<h2 id="粒度判準">粒度判準</h2>
<p>事件粒度的判斷用一個 SRP 判準：<strong>一個事件記一個事實</strong>。</p>
<h3 id="拆分訊號">拆分訊號</h3>
<p>一個事件記了兩個獨立的事實 → 拆成兩個事件。</p>
<p><code>terminal.connect_and_auth</code> 同時記錄「連線建立」和「認證通過」。這兩個事實的失敗模式不同（連線失敗是網路問題、認證失敗是帳密問題）、觸發時機不同、消費者不同。拆成 <code>terminal.connect.done</code> 和 <code>auth.token.sent</code>。</p>
<h3 id="合併訊號">合併訊號</h3>
<p>兩個事件永遠同時觸發且消費者相同 → 合併成一個事件。</p>
<p><code>terminal.input.keystroke</code> 和 <code>terminal.input.keystroke_logged</code> 永遠同時觸發（每個按鍵一次），data schema 相同。合併成一個 <code>terminal.input.keystroke</code>。</p>
<h3 id="邊界案例">邊界案例</h3>
<p><code>connect.done</code> 同時記 event 和 metric（成功事件 + duration）。這是一個事實（連線完成）的兩個面向，可以合併成一個事件帶 <code>duration_ms</code> 欄位，也可以拆成 event 和 metric 兩筆。判斷依據是查詢需求 — 如果 funnel 分析和效能分析會分開查，拆開讓各自的查詢更簡單；如果都在同一個 dashboard 看，合併減少事件量。</p>
<h2 id="data-schema-設計">data schema 設計</h2>
<p>每個事件的 data 欄位回答「發生了什麼的 context」。設計原則：</p>
<p><strong>帶足 debug context</strong>：error 事件的 data 至少包含 error message、發生的步驟、當時的關鍵狀態值。看到這筆 error 事件時、開發者不需要再去查其他來源就能判斷問題方向。</p>
<p><strong>避免過度收集</strong>：data 只帶回答具體問題需要的欄位。<code>terminal.connect.start</code> 帶 URL 和觸發方式就夠了；不需要帶使用者的全部設定。</p>
<p><strong>敏感欄位標記 redaction</strong>：URL 可能含 IP、error message 可能含路徑中的使用者名稱。在事件設計階段標記需要 <a href="/blog/monitoring/knowledge-cards/redaction/" data-link-title="Redaction" data-link-desc="說明在事件資料離開 client 之前把敏感欄位的值替換成遮罩或移除的機制">redaction</a> 的欄位，SDK 實作時自動處理。</p>
<h2 id="事件表的產出格式">事件表的產出格式</h2>
<p>完整的事件表每列七欄：</p>
<table>
  <thead>
      <tr>
          <th>事件名稱</th>
          <th>類型</th>
          <th>觸發時機</th>
          <th>data schema</th>
          <th>redaction 欄位</th>
          <th>保留層級</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>terminal.connect.start</td>
          <td>event</td>
          <td>使用者點擊連線</td>
          <td><code>{url, trigger}</code></td>
          <td>url</td>
          <td>原始 7d</td>
          <td>funnel 第一步</td>
      </tr>
  </tbody>
</table>
<p>保留層級欄對應分層保留策略 — 哪些事件需要保留原始逐筆資料（debug 用）、哪些只需要聚合摘要（趨勢用）。</p>
<p>事件表是 SDK 埋點的 spec — 開發者照表實作，code review 時逐行勾選。和<a href="/blog/testing/02-client-observability/log-point-in-spec/" data-link-title="功能規格中的 log 點定義方法" data-link-desc="把 log 點設計從 debug 階段前移到功能規格階段 — 每個功能的規格文件新增可觀測性欄位，列出啟動 / 步驟 / 錯誤 / 完成四類 log 點">功能規格中的 log 點定義</a>互補 — log 點是開發期的 debug 設計，事件表是監控期的收集設計。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>四類事件的定義 → <a href="/blog/monitoring/01-mental-model/four-event-types/" data-link-title="四類事件的完整定義" data-link-desc="Event / Error / Metric / Lifecycle 四類事件各自的語意、觸發時機和典型用途 — 分類是監控體系的統一語言">四類事件的完整定義</a></li>
<li>事件命名規範 → <a href="/blog/monitoring/01-mental-model/event-naming-convention/" data-link-title="事件命名規範" data-link-desc="namespace.action 格式的事件命名、命名一致性的工程價值、和商業方案命名慣例的對應">事件命名規範</a></li>
<li>行為事件的 funnel 設計 → <a href="/blog/monitoring/08-business-analytics/behavior-event-design/" data-link-title="行為事件設計" data-link-desc="事件命名規範、屬性設計、funnel 定義 — 行為分析的品質取決於事件設計的品質">行為事件設計</a></li>
<li>事件 schema 的欄位定義 → <a href="/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">event.schema.json 完整欄位解說</a></li>
<li>動機驅動的具體事件對應 → <a href="/blog/monitoring/01-mental-model/motivation-to-event-mapping/" data-link-title="動機驅動的事件設計" data-link-desc="Debug / 商業 / 資安 / 效能四個動機各自需要什麼事件 — 從「為什麼收」反推「收什麼」和「什麼階段啟用」">動機驅動的事件設計</a></li>
</ul>
]]></content:encoded></item><item><title>A/B Test 的統計基礎</title><link>https://tarrragon.github.io/blog/monitoring/08-business-analytics/ab-test-statistics/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/08-business-analytics/ab-test-statistics/</guid><description>&lt;p>A/B test 把使用者隨機分成兩組，一組看到原版（control），一組看到改版（treatment），比較兩組的指標差異。統計方法的角色是判斷「觀察到的差異是真實的還是隨機波動」。&lt;/p>
&lt;h2 id="假設檢定">假設檢定&lt;/h2>
&lt;h3 id="虛無假設和對立假設">虛無假設和對立假設&lt;/h3>
&lt;p>虛無假設（H0）：兩組沒有差異，觀察到的差異來自隨機波動。對立假設（H1）：兩組有真實差異。&lt;/p>
&lt;p>A/B test 的邏輯是：假設 H0 成立（兩組沒有差異），計算「在 H0 成立的前提下，觀察到目前這麼大的差異的機率」。如果這個機率（p-value）很小（通常 &amp;lt; 0.05），拒絕 H0，接受 H1。&lt;/p>
&lt;h3 id="p-value-的意義">p-value 的意義&lt;/h3>
&lt;p>p-value = 0.03 代表「假設兩組沒有差異，觀察到目前差異的機率是 3%」。這個機率足夠小，合理推斷差異是真實的。&lt;/p>
&lt;p>p-value 不代表「改版比原版好的機率是 97%」。p-value 是在 H0 成立的條件下計算的，不是改版效果的機率。&lt;/p>
&lt;h3 id="兩類錯誤">兩類錯誤&lt;/h3>
&lt;p>&lt;strong>Type I error（偽陽性）&lt;/strong>：實際上沒有差異，但統計結果判定有差異。機率由顯著性水準 α 控制，通常設 0.05。&lt;/p>
&lt;p>&lt;strong>Type II error（偽陰性）&lt;/strong>：實際上有差異，但統計結果判定沒有差異。機率由統計檢定力（power = 1 - β）控制，通常要求 power ≥ 0.8。&lt;/p>
&lt;h2 id="樣本量計算">樣本量計算&lt;/h2>
&lt;p>樣本量決定了 A/B test 能偵測到多小的差異。樣本量太小，即使改版有效果，test 也沒有足夠的統計檢定力偵測到。&lt;/p>
&lt;p>樣本量計算需要四個參數：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>基準轉換率&lt;/strong>：control 組目前的轉換率（例如 5%）&lt;/li>
&lt;li>&lt;strong>最小可偵測效果（MDE）&lt;/strong>：想偵測到的最小差異（例如 5% → 6%，相對提升 20%）&lt;/li>
&lt;li>&lt;strong>顯著性水準 α&lt;/strong>：通常 0.05&lt;/li>
&lt;li>&lt;strong>統計檢定力 1 - β&lt;/strong>：通常 0.8&lt;/li>
&lt;/ul>
&lt;p>以基準轉換率 5%、MDE 相對提升 20%（5% → 6%）、α = 0.05、power = 0.8 為例，每組需要約 14,500 個樣本。如果每天有 1,000 個使用者，需要跑 29 天。&lt;/p>
&lt;p>樣本量不足時的常見錯誤是「提早看結果」— 跑了 3 天看到 p &amp;lt; 0.05 就停止。提早停止會膨脹 Type I error 率，因為隨機波動在小樣本中更容易產生看似顯著的差異。&lt;/p>
&lt;h2 id="多重比較">多重比較&lt;/h2>
&lt;p>同時跑多個 A/B test 或測試多個變體（A/B/C/D）時，整體的 Type I error 率會膨脹。&lt;/p>
&lt;p>跑 20 個 test，即使所有 test 的 H0 都成立（沒有真實差異），預期有 1 個 test（20 × 0.05）會出現 p &amp;lt; 0.05 的偽陽性。&lt;/p>
&lt;h3 id="bonferroni-校正">Bonferroni 校正&lt;/h3>
&lt;p>最簡單的校正方式：把顯著性水準除以測試數量。跑 5 個 test，每個 test 的顯著性水準改為 0.05 / 5 = 0.01。&lt;/p>
&lt;p>Bonferroni 校正很保守 — 降低了偽陽性但也降低了統計檢定力，可能錯過真實的差異。&lt;/p>
&lt;h3 id="false-discovery-ratefdr">False Discovery Rate（FDR）&lt;/h3>
&lt;p>Benjamini-Hochberg 方法控制的是「被判為顯著的結果中偽陽性的比例」，比 Bonferroni 更寬鬆。適合探索性分析（同時測試多個指標，容許一些偽陽性）。&lt;/p>
&lt;h2 id="ab-test-在自架方案的可行性">A/B test 在自架方案的可行性&lt;/h2>
&lt;p>自架 collector 可以做基礎的 A/B test 分析 — 在行為事件中記錄使用者的分組（&lt;code>variant: &amp;quot;control&amp;quot;&lt;/code> / &lt;code>variant: &amp;quot;treatment&amp;quot;&lt;/code>），計算每組的轉換率，用統計檢定比較差異。&lt;/p>
&lt;p>統計計算（p-value、信賴區間）可以用 Python（scipy.stats）或 R 完成。不需要商業 A/B test 平台。&lt;/p>
&lt;p>商業 A/B test 平台（Optimizely、LaunchDarkly、Firebase Remote Config）額外提供的是：隨機分組管理、提早停止的統計保護（sequential testing）、多變體管理的 UI、和其他分析工具的整合。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>推薦系統概論 → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/recommendation-overview/" data-link-title="推薦系統概論" data-link-desc="Collaborative filtering / content-based / 混合方法 — 推薦系統的三種基本架構和各自的資料需求">推薦系統概論&lt;/a>&lt;/li>
&lt;li>使用者分群 → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/rfm-segmentation/" data-link-title="RFM 分群" data-link-desc="Recency / Frequency / Monetary 三維度的使用者分群 — 從行為事件計算 RFM 分數、定義使用者群體、驅動差異化策略">RFM 分群&lt;/a>&lt;/li>
&lt;li>行為事件設計 → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/behavior-event-design/" data-link-title="行為事件設計" data-link-desc="事件命名規範、屬性設計、funnel 定義 — 行為分析的品質取決於事件設計的品質">行為事件設計&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>A/B test 把使用者隨機分成兩組，一組看到原版（control），一組看到改版（treatment），比較兩組的指標差異。統計方法的角色是判斷「觀察到的差異是真實的還是隨機波動」。</p>
<h2 id="假設檢定">假設檢定</h2>
<h3 id="虛無假設和對立假設">虛無假設和對立假設</h3>
<p>虛無假設（H0）：兩組沒有差異，觀察到的差異來自隨機波動。對立假設（H1）：兩組有真實差異。</p>
<p>A/B test 的邏輯是：假設 H0 成立（兩組沒有差異），計算「在 H0 成立的前提下，觀察到目前這麼大的差異的機率」。如果這個機率（p-value）很小（通常 &lt; 0.05），拒絕 H0，接受 H1。</p>
<h3 id="p-value-的意義">p-value 的意義</h3>
<p>p-value = 0.03 代表「假設兩組沒有差異，觀察到目前差異的機率是 3%」。這個機率足夠小，合理推斷差異是真實的。</p>
<p>p-value 不代表「改版比原版好的機率是 97%」。p-value 是在 H0 成立的條件下計算的，不是改版效果的機率。</p>
<h3 id="兩類錯誤">兩類錯誤</h3>
<p><strong>Type I error（偽陽性）</strong>：實際上沒有差異，但統計結果判定有差異。機率由顯著性水準 α 控制，通常設 0.05。</p>
<p><strong>Type II error（偽陰性）</strong>：實際上有差異，但統計結果判定沒有差異。機率由統計檢定力（power = 1 - β）控制，通常要求 power ≥ 0.8。</p>
<h2 id="樣本量計算">樣本量計算</h2>
<p>樣本量決定了 A/B test 能偵測到多小的差異。樣本量太小，即使改版有效果，test 也沒有足夠的統計檢定力偵測到。</p>
<p>樣本量計算需要四個參數：</p>
<ul>
<li><strong>基準轉換率</strong>：control 組目前的轉換率（例如 5%）</li>
<li><strong>最小可偵測效果（MDE）</strong>：想偵測到的最小差異（例如 5% → 6%，相對提升 20%）</li>
<li><strong>顯著性水準 α</strong>：通常 0.05</li>
<li><strong>統計檢定力 1 - β</strong>：通常 0.8</li>
</ul>
<p>以基準轉換率 5%、MDE 相對提升 20%（5% → 6%）、α = 0.05、power = 0.8 為例，每組需要約 14,500 個樣本。如果每天有 1,000 個使用者，需要跑 29 天。</p>
<p>樣本量不足時的常見錯誤是「提早看結果」— 跑了 3 天看到 p &lt; 0.05 就停止。提早停止會膨脹 Type I error 率，因為隨機波動在小樣本中更容易產生看似顯著的差異。</p>
<h2 id="多重比較">多重比較</h2>
<p>同時跑多個 A/B test 或測試多個變體（A/B/C/D）時，整體的 Type I error 率會膨脹。</p>
<p>跑 20 個 test，即使所有 test 的 H0 都成立（沒有真實差異），預期有 1 個 test（20 × 0.05）會出現 p &lt; 0.05 的偽陽性。</p>
<h3 id="bonferroni-校正">Bonferroni 校正</h3>
<p>最簡單的校正方式：把顯著性水準除以測試數量。跑 5 個 test，每個 test 的顯著性水準改為 0.05 / 5 = 0.01。</p>
<p>Bonferroni 校正很保守 — 降低了偽陽性但也降低了統計檢定力，可能錯過真實的差異。</p>
<h3 id="false-discovery-ratefdr">False Discovery Rate（FDR）</h3>
<p>Benjamini-Hochberg 方法控制的是「被判為顯著的結果中偽陽性的比例」，比 Bonferroni 更寬鬆。適合探索性分析（同時測試多個指標，容許一些偽陽性）。</p>
<h2 id="ab-test-在自架方案的可行性">A/B test 在自架方案的可行性</h2>
<p>自架 collector 可以做基礎的 A/B test 分析 — 在行為事件中記錄使用者的分組（<code>variant: &quot;control&quot;</code> / <code>variant: &quot;treatment&quot;</code>），計算每組的轉換率，用統計檢定比較差異。</p>
<p>統計計算（p-value、信賴區間）可以用 Python（scipy.stats）或 R 完成。不需要商業 A/B test 平台。</p>
<p>商業 A/B test 平台（Optimizely、LaunchDarkly、Firebase Remote Config）額外提供的是：隨機分組管理、提早停止的統計保護（sequential testing）、多變體管理的 UI、和其他分析工具的整合。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>推薦系統概論 → <a href="/blog/monitoring/08-business-analytics/recommendation-overview/" data-link-title="推薦系統概論" data-link-desc="Collaborative filtering / content-based / 混合方法 — 推薦系統的三種基本架構和各自的資料需求">推薦系統概論</a></li>
<li>使用者分群 → <a href="/blog/monitoring/08-business-analytics/rfm-segmentation/" data-link-title="RFM 分群" data-link-desc="Recency / Frequency / Monetary 三維度的使用者分群 — 從行為事件計算 RFM 分數、定義使用者群體、驅動差異化策略">RFM 分群</a></li>
<li>行為事件設計 → <a href="/blog/monitoring/08-business-analytics/behavior-event-design/" data-link-title="行為事件設計" data-link-desc="事件命名規範、屬性設計、funnel 定義 — 行為分析的品質取決於事件設計的品質">行為事件設計</a></li>
</ul>
]]></content:encoded></item><item><title>GDPR 最小化原則的工程落地</title><link>https://tarrragon.github.io/blog/monitoring/07-security-privacy/gdpr-minimization/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/07-security-privacy/gdpr-minimization/</guid><description>&lt;p>GDPR 的資料最小化原則要求「只收集達成特定目的所需的最少資料」。這個法律原則轉譯到監控系統的工程實作，影響三個設計決策：收集什麼欄位、保留多久、誰可以存取。&lt;/p>
&lt;h2 id="資料最小化只收集需要的欄位">資料最小化：只收集需要的欄位&lt;/h2>
&lt;p>資料最小化的工程落地是「每個收集的欄位都要能回答：這個欄位用來做什麼決策？」。如果一個欄位只是「可能有用」但沒有明確的消費場景，就不應該收集。&lt;/p>
&lt;h3 id="正面表列-vs-負面排除">正面表列 vs 負面排除&lt;/h3>
&lt;p>正面表列（allowlist）是列出「收集哪些欄位」— 只收集清單上的欄位，其他全部不收。&lt;/p>
&lt;p>負面排除（denylist）是列出「不收集哪些欄位」— 預設收集所有欄位，排除清單上的。&lt;/p>
&lt;p>GDPR 的精神更接近正面表列 — 每個收集行為需要有正當理由（lawful basis）。工程上的實作方式是：事件 schema 定義哪些欄位是允許的，不在 schema 中的欄位在 collector 端丟棄。&lt;/p>
&lt;h3 id="sdk-端的最小化">SDK 端的最小化&lt;/h3>
&lt;p>SDK 端的最小化更主動 — 在事件產生時就只包含必要的欄位，而非送到 collector 再過濾。&lt;/p>
&lt;p>設計 SDK 的 event API 時，不提供「送任意 key-value」的 free-form API，而是提供結構化的 API：&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">// free-form（難以控制收集了什麼）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">monitor.event(&amp;#39;login&amp;#39;, data: {&amp;#39;email&amp;#39;: email, &amp;#39;ip&amp;#39;: ip, &amp;#39;device&amp;#39;: device, ...})
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">// 結構化（schema 控制收集範圍）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">monitor.event(&amp;#39;login&amp;#39;, loginMethod: &amp;#39;biometric&amp;#39;, success: true)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>結構化 API 的參數在 SDK 設計時就決定了收集範圍，code review 時可以檢查「為什麼這個 event 需要這個參數」。&lt;/p>
&lt;h2 id="目的限制收集的資料只用於聲明的目的">目的限制：收集的資料只用於聲明的目的&lt;/h2>
&lt;p>目的限制要求資料只用於收集時聲明的目的。監控系統收集事件的目的通常是 debug 和效能監控 — 如果之後要用同一份資料做行為分析或廣告投放，需要額外的法律基礎（通常是使用者同意）。&lt;/p>
&lt;h3 id="工程落地">工程落地&lt;/h3>
&lt;p>目的限制在工程上的實作是「不同目的的資料分開儲存、分開授權」。&lt;/p>
&lt;p>Debug 用的 error 事件和行為分析用的 event 事件存在不同的儲存位置（不同的 JSONL 檔案或不同的資料庫 table）。Debug 用途的 access 不需要使用者同意（legitimate interest）；行為分析用途的 access 需要使用者同意。&lt;/p>
&lt;p>分開儲存讓「使用者撤回行為分析同意」的工程操作變簡單 — 刪除行為分析的儲存，不影響 debug 儲存。&lt;/p>
&lt;h2 id="儲存限制不保留超過必要期間的資料">儲存限制：不保留超過必要期間的資料&lt;/h2>
&lt;p>儲存限制要求資料只保留達成目的所需的最短期間。監控資料的合理保留期間依用途不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>用途&lt;/th>
 &lt;th>合理保留期間&lt;/th>
 &lt;th>理由&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Debug&lt;/td>
 &lt;td>30-90 天&lt;/td>
 &lt;td>大部分 bug 在 30 天內被發現和修復&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>效能趨勢&lt;/td>
 &lt;td>6-12 個月&lt;/td>
 &lt;td>季節性趨勢需要至少一年的資料&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>行為分析&lt;/td>
 &lt;td>依同意期間&lt;/td>
 &lt;td>使用者同意到期就刪除&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>合規審計&lt;/td>
 &lt;td>依法規要求（通常 1-7 年）&lt;/td>
 &lt;td>法規指定的最短保留期間&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="自動清理">自動清理&lt;/h3>
&lt;p>Collector 的儲存清理應該自動化 — 手動清理依賴人記得執行，最終會被遺忘。&lt;/p>
&lt;p>JSONL 儲存用「一天一檔」的命名（&lt;code>events-2026-06-19.jsonl&lt;/code>），清理腳本每天刪除超過保留期限的檔案。Cron job 或 systemd timer 定期執行。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>去識別化技術 → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/anonymization-strategy/" data-link-title="去識別化策略" data-link-desc="IP 截斷 / user agent 簡化 / stack trace 路徑清理 / session UUID — 四種去識別化技術的適用場景和實作方式">去識別化策略&lt;/a>&lt;/li>
&lt;li>監控資料洩漏的威脅分析 → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/monitoring-data-threat-model/" data-link-title="監控資料洩漏的 Threat Model" data-link-desc="監控系統本身是攻擊面 — 四個威脅場景（傳輸竊聽 / 儲存入侵 / endpoint 濫用 / 內部越權存取）的風險評估和防護措施">監控資料洩漏的 threat model&lt;/a>&lt;/li>
&lt;li>Collector 的儲存設計 → &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>GDPR 的資料最小化原則要求「只收集達成特定目的所需的最少資料」。這個法律原則轉譯到監控系統的工程實作，影響三個設計決策：收集什麼欄位、保留多久、誰可以存取。</p>
<h2 id="資料最小化只收集需要的欄位">資料最小化：只收集需要的欄位</h2>
<p>資料最小化的工程落地是「每個收集的欄位都要能回答：這個欄位用來做什麼決策？」。如果一個欄位只是「可能有用」但沒有明確的消費場景，就不應該收集。</p>
<h3 id="正面表列-vs-負面排除">正面表列 vs 負面排除</h3>
<p>正面表列（allowlist）是列出「收集哪些欄位」— 只收集清單上的欄位，其他全部不收。</p>
<p>負面排除（denylist）是列出「不收集哪些欄位」— 預設收集所有欄位，排除清單上的。</p>
<p>GDPR 的精神更接近正面表列 — 每個收集行為需要有正當理由（lawful basis）。工程上的實作方式是：事件 schema 定義哪些欄位是允許的，不在 schema 中的欄位在 collector 端丟棄。</p>
<h3 id="sdk-端的最小化">SDK 端的最小化</h3>
<p>SDK 端的最小化更主動 — 在事件產生時就只包含必要的欄位，而非送到 collector 再過濾。</p>
<p>設計 SDK 的 event API 時，不提供「送任意 key-value」的 free-form API，而是提供結構化的 API：</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">// free-form（難以控制收集了什麼）
</span></span><span class="line"><span class="ln">2</span><span class="cl">monitor.event(&#39;login&#39;, data: {&#39;email&#39;: email, &#39;ip&#39;: ip, &#39;device&#39;: device, ...})
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">// 結構化（schema 控制收集範圍）
</span></span><span class="line"><span class="ln">5</span><span class="cl">monitor.event(&#39;login&#39;, loginMethod: &#39;biometric&#39;, success: true)</span></span></code></pre></div><p>結構化 API 的參數在 SDK 設計時就決定了收集範圍，code review 時可以檢查「為什麼這個 event 需要這個參數」。</p>
<h2 id="目的限制收集的資料只用於聲明的目的">目的限制：收集的資料只用於聲明的目的</h2>
<p>目的限制要求資料只用於收集時聲明的目的。監控系統收集事件的目的通常是 debug 和效能監控 — 如果之後要用同一份資料做行為分析或廣告投放，需要額外的法律基礎（通常是使用者同意）。</p>
<h3 id="工程落地">工程落地</h3>
<p>目的限制在工程上的實作是「不同目的的資料分開儲存、分開授權」。</p>
<p>Debug 用的 error 事件和行為分析用的 event 事件存在不同的儲存位置（不同的 JSONL 檔案或不同的資料庫 table）。Debug 用途的 access 不需要使用者同意（legitimate interest）；行為分析用途的 access 需要使用者同意。</p>
<p>分開儲存讓「使用者撤回行為分析同意」的工程操作變簡單 — 刪除行為分析的儲存，不影響 debug 儲存。</p>
<h2 id="儲存限制不保留超過必要期間的資料">儲存限制：不保留超過必要期間的資料</h2>
<p>儲存限制要求資料只保留達成目的所需的最短期間。監控資料的合理保留期間依用途不同：</p>
<table>
  <thead>
      <tr>
          <th>用途</th>
          <th>合理保留期間</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Debug</td>
          <td>30-90 天</td>
          <td>大部分 bug 在 30 天內被發現和修復</td>
      </tr>
      <tr>
          <td>效能趨勢</td>
          <td>6-12 個月</td>
          <td>季節性趨勢需要至少一年的資料</td>
      </tr>
      <tr>
          <td>行為分析</td>
          <td>依同意期間</td>
          <td>使用者同意到期就刪除</td>
      </tr>
      <tr>
          <td>合規審計</td>
          <td>依法規要求（通常 1-7 年）</td>
          <td>法規指定的最短保留期間</td>
      </tr>
  </tbody>
</table>
<h3 id="自動清理">自動清理</h3>
<p>Collector 的儲存清理應該自動化 — 手動清理依賴人記得執行，最終會被遺忘。</p>
<p>JSONL 儲存用「一天一檔」的命名（<code>events-2026-06-19.jsonl</code>），清理腳本每天刪除超過保留期限的檔案。Cron job 或 systemd timer 定期執行。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>去識別化技術 → <a href="/blog/monitoring/07-security-privacy/anonymization-strategy/" data-link-title="去識別化策略" data-link-desc="IP 截斷 / user agent 簡化 / stack trace 路徑清理 / session UUID — 四種去識別化技術的適用場景和實作方式">去識別化策略</a></li>
<li>監控資料洩漏的威脅分析 → <a href="/blog/monitoring/07-security-privacy/monitoring-data-threat-model/" data-link-title="監控資料洩漏的 Threat Model" data-link-desc="監控系統本身是攻擊面 — 四個威脅場景（傳輸竊聽 / 儲存入侵 / endpoint 濫用 / 內部越權存取）的風險評估和防護措施">監控資料洩漏的 threat model</a></li>
<li>Collector 的儲存設計 → <a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計</a></li>
</ul>
]]></content:encoded></item><item><title>Mixpanel / Amplitude</title><link>https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/mixpanel-amplitude/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/mixpanel-amplitude/</guid><description>&lt;p>Mixpanel 和 Amplitude 是行為分析（product analytics）專用方案。核心功能是 funnel analysis、cohort analysis、retention analysis — 回答「使用者怎麼使用產品」。和 Sentry（error-first）、Datadog（APM-first）的定位有本質差異：行為分析的消費者是產品團隊，通用監控的消費者是工程團隊。&lt;/p>
&lt;h2 id="行為分析-vs-通用監控">行為分析 vs 通用監控&lt;/h2>
&lt;p>通用監控方案（Sentry、Crashlytics、Datadog）的主要產出是 error 報告和 performance 數據 — 工程團隊用來修復 bug 和優化效能。&lt;/p>
&lt;p>行為分析方案的主要產出是 funnel 和 cohort 數據 — 產品團隊用來決定功能優先順序、評估改版效果、優化使用者體驗。&lt;/p>
&lt;p>兩類需求可以共存。工程團隊需要 error tracking，產品團隊需要行為分析。一些團隊同時使用 Sentry + Mixpanel，各自服務不同的消費者。&lt;/p>
&lt;h2 id="核心功能">核心功能&lt;/h2>
&lt;h3 id="funnel-analysis">Funnel analysis&lt;/h3>
&lt;p>定義使用者操作的步驟序列，計算每步的轉換率和流失率。Mixpanel 和 Amplitude 的 funnel 分析支援：步驟之間的時間窗口限制（步驟 1 到步驟 2 在 24 小時內完成才算轉換）、按使用者屬性分群（新使用者 vs 老使用者的轉換率差異）、步驟之間的路徑分析（流失的使用者去了哪裡）。&lt;/p>
&lt;p>自架方案能做基礎的 funnel 計數（&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 工具到商業資產的翻轉">模組八 自架 funnel&lt;/a>），但不支援時間窗口、分群和路徑分析。&lt;/p>
&lt;h3 id="cohort-analysis">Cohort analysis&lt;/h3>
&lt;p>按使用者屬性或行為把使用者分成群組，比較不同群組的行為差異。例：「從 Google 廣告來的使用者」vs「從社群分享來的使用者」，兩組的留存率和付費率差異。&lt;/p>
&lt;h3 id="retention-analysis">Retention analysis&lt;/h3>
&lt;p>追蹤使用者在初次使用後的回訪率。Day 1 / Day 7 / Day 30 retention — 多少使用者在首次使用後 1 天 / 7 天 / 30 天內回來。&lt;/p>
&lt;p>Retention 是產品健康度的核心指標。行為分析方案提供 retention curve（留存曲線）和 retention by cohort（不同群組的留存差異），這些在自架方案中需要大量的 SQL 查詢和手動計算。&lt;/p>
&lt;h2 id="mixpanel-vs-amplitude-的差異">Mixpanel vs Amplitude 的差異&lt;/h2>
&lt;p>兩者的功能高度重疊，差異主要在定價和資料模型：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Mixpanel&lt;/th>
 &lt;th>Amplitude&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>定價模型&lt;/td>
 &lt;td>按事件量計費&lt;/td>
 &lt;td>按 MTU（月活使用者）計費&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>資料模型&lt;/td>
 &lt;td>event-centric（事件為中心）&lt;/td>
 &lt;td>event + user profile&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SQL 查詢&lt;/td>
 &lt;td>JQL（自訂查詢語言）&lt;/td>
 &lt;td>原生 SQL 支援（Amplitude SQL）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>免費額度&lt;/td>
 &lt;td>每月 2000 萬事件&lt;/td>
 &lt;td>每月 1000 萬事件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>整合&lt;/td>
 &lt;td>豐富的第三方整合&lt;/td>
 &lt;td>CDP（Customer Data Platform）強&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>選擇依據通常是團隊的既有工具鏈和定價模型偏好。&lt;/p>
&lt;h2 id="什麼時候需要行為分析方案">什麼時候需要行為分析方案&lt;/h2>
&lt;p>行為分析方案的投資在以下條件下有回報：&lt;/p>
&lt;p>&lt;strong>有產品團隊消費數據&lt;/strong>：如果只有工程團隊，error tracking + 自架 log 通常足夠。行為分析方案的 dashboard 需要產品團隊定期查看和基於數據做決策。&lt;/p>
&lt;p>&lt;strong>使用者數量足夠產生統計意義&lt;/strong>：Funnel 和 cohort 分析需要足夠的樣本量。DAU &amp;lt; 100 的產品，分析結果的統計信度低。&lt;/p>
&lt;p>&lt;strong>有明確的優化目標&lt;/strong>：「提高註冊轉換率」「降低 Day 7 流失率」— 有具體的 metric 目標，行為分析方案能提供追蹤和歸因。&lt;/p>
&lt;p>自用工具場景下不需要行為分析方案 — 使用者就是開發者本人，行為數據沒有分析價值。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>自架 vs 商業的判斷 → &lt;a href="https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/self-hosted-vs-commercial/" data-link-title="自架 vs 商業的判斷決策表" data-link-desc="使用者數、網路範圍、功能需求、合規要求四個維度判斷該自架還是用商業方案">自架 vs 商業的判斷決策表&lt;/a>&lt;/li>
&lt;li>行為分析的方法論 → &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;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/commercial-event-mapping/" data-link-title="商業方案的事件類型對應" data-link-desc="Sentry / Crashlytics / GA4 / Datadog RUM 各自如何對應四類事件 — 理解商業方案的分類邏輯才能正確接入">模組一 商業方案事件類型對應&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Mixpanel 和 Amplitude 是行為分析（product analytics）專用方案。核心功能是 funnel analysis、cohort analysis、retention analysis — 回答「使用者怎麼使用產品」。和 Sentry（error-first）、Datadog（APM-first）的定位有本質差異：行為分析的消費者是產品團隊，通用監控的消費者是工程團隊。</p>
<h2 id="行為分析-vs-通用監控">行為分析 vs 通用監控</h2>
<p>通用監控方案（Sentry、Crashlytics、Datadog）的主要產出是 error 報告和 performance 數據 — 工程團隊用來修復 bug 和優化效能。</p>
<p>行為分析方案的主要產出是 funnel 和 cohort 數據 — 產品團隊用來決定功能優先順序、評估改版效果、優化使用者體驗。</p>
<p>兩類需求可以共存。工程團隊需要 error tracking，產品團隊需要行為分析。一些團隊同時使用 Sentry + Mixpanel，各自服務不同的消費者。</p>
<h2 id="核心功能">核心功能</h2>
<h3 id="funnel-analysis">Funnel analysis</h3>
<p>定義使用者操作的步驟序列，計算每步的轉換率和流失率。Mixpanel 和 Amplitude 的 funnel 分析支援：步驟之間的時間窗口限制（步驟 1 到步驟 2 在 24 小時內完成才算轉換）、按使用者屬性分群（新使用者 vs 老使用者的轉換率差異）、步驟之間的路徑分析（流失的使用者去了哪裡）。</p>
<p>自架方案能做基礎的 funnel 計數（<a href="/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">模組八 自架 funnel</a>），但不支援時間窗口、分群和路徑分析。</p>
<h3 id="cohort-analysis">Cohort analysis</h3>
<p>按使用者屬性或行為把使用者分成群組，比較不同群組的行為差異。例：「從 Google 廣告來的使用者」vs「從社群分享來的使用者」，兩組的留存率和付費率差異。</p>
<h3 id="retention-analysis">Retention analysis</h3>
<p>追蹤使用者在初次使用後的回訪率。Day 1 / Day 7 / Day 30 retention — 多少使用者在首次使用後 1 天 / 7 天 / 30 天內回來。</p>
<p>Retention 是產品健康度的核心指標。行為分析方案提供 retention curve（留存曲線）和 retention by cohort（不同群組的留存差異），這些在自架方案中需要大量的 SQL 查詢和手動計算。</p>
<h2 id="mixpanel-vs-amplitude-的差異">Mixpanel vs Amplitude 的差異</h2>
<p>兩者的功能高度重疊，差異主要在定價和資料模型：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Mixpanel</th>
          <th>Amplitude</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>定價模型</td>
          <td>按事件量計費</td>
          <td>按 MTU（月活使用者）計費</td>
      </tr>
      <tr>
          <td>資料模型</td>
          <td>event-centric（事件為中心）</td>
          <td>event + user profile</td>
      </tr>
      <tr>
          <td>SQL 查詢</td>
          <td>JQL（自訂查詢語言）</td>
          <td>原生 SQL 支援（Amplitude SQL）</td>
      </tr>
      <tr>
          <td>免費額度</td>
          <td>每月 2000 萬事件</td>
          <td>每月 1000 萬事件</td>
      </tr>
      <tr>
          <td>整合</td>
          <td>豐富的第三方整合</td>
          <td>CDP（Customer Data Platform）強</td>
      </tr>
  </tbody>
</table>
<p>選擇依據通常是團隊的既有工具鏈和定價模型偏好。</p>
<h2 id="什麼時候需要行為分析方案">什麼時候需要行為分析方案</h2>
<p>行為分析方案的投資在以下條件下有回報：</p>
<p><strong>有產品團隊消費數據</strong>：如果只有工程團隊，error tracking + 自架 log 通常足夠。行為分析方案的 dashboard 需要產品團隊定期查看和基於數據做決策。</p>
<p><strong>使用者數量足夠產生統計意義</strong>：Funnel 和 cohort 分析需要足夠的樣本量。DAU &lt; 100 的產品，分析結果的統計信度低。</p>
<p><strong>有明確的優化目標</strong>：「提高註冊轉換率」「降低 Day 7 流失率」— 有具體的 metric 目標，行為分析方案能提供追蹤和歸因。</p>
<p>自用工具場景下不需要行為分析方案 — 使用者就是開發者本人，行為數據沒有分析價值。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>自架 vs 商業的判斷 → <a href="/blog/monitoring/06-commercial-comparison/self-hosted-vs-commercial/" data-link-title="自架 vs 商業的判斷決策表" data-link-desc="使用者數、網路範圍、功能需求、合規要求四個維度判斷該自架還是用商業方案">自架 vs 商業的判斷決策表</a></li>
<li>行為分析的方法論 → <a href="/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">模組八 行為資料的商業利用</a></li>
<li>四類事件在商業方案中的對應 → <a href="/blog/monitoring/01-mental-model/commercial-event-mapping/" data-link-title="商業方案的事件類型對應" data-link-desc="Sentry / Crashlytics / GA4 / Datadog RUM 各自如何對應四類事件 — 理解商業方案的分類邏輯才能正確接入">模組一 商業方案事件類型對應</a></li>
</ul>
]]></content:encoded></item><item><title>SDK redaction helper</title><link>https://tarrragon.github.io/blog/monitoring/03-sdk-design/redaction-helper/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/03-sdk-design/redaction-helper/</guid><description>&lt;p>SDK &lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/redaction/" data-link-title="Redaction" data-link-desc="說明在事件資料離開 client 之前把敏感欄位的值替換成遮罩或移除的機制">redaction&lt;/a> helper 在事件離開 SDK（進入 HTTP POST payload）前掃描事件內容，把匹配敏感資訊 pattern 的欄位值替換為 &lt;code>[REDACTED]&lt;/code>。Redaction 在 SDK 端執行，確保敏感資訊不會經過網路傳輸到 collector — 即使 transport 層被攔截，攻擊者看到的也是脫敏後的資料。&lt;/p>
&lt;h2 id="預設-redaction-rule">預設 redaction rule&lt;/h2>
&lt;p>SDK 內建一組預設 rule，處理常見的敏感資訊 pattern：&lt;/p>
&lt;h3 id="密碼欄位">密碼欄位&lt;/h3>
&lt;p>匹配 data 物件中 key 包含 &lt;code>password&lt;/code>、&lt;code>passwd&lt;/code>、&lt;code>secret&lt;/code>、&lt;code>token&lt;/code>、&lt;code>api_key&lt;/code>、&lt;code>apiKey&lt;/code>、&lt;code>authorization&lt;/code> 的欄位。匹配方式是 key 名稱的子字串比對（case-insensitive）。&lt;/p>
&lt;h3 id="url-中的認證資訊">URL 中的認證資訊&lt;/h3>
&lt;p>匹配 &lt;code>https://user:password@host&lt;/code> 格式的 URL，把 &lt;code>user:password&lt;/code> 部分替換為 &lt;code>[REDACTED]&lt;/code>。&lt;/p>
&lt;h3 id="stack-trace-中的檔案路徑">Stack trace 中的檔案路徑&lt;/h3>
&lt;p>匹配 stack trace 字串中的使用者目錄路徑（&lt;code>/Users/username/&lt;/code>、&lt;code>/home/username/&lt;/code>、&lt;code>C:\Users\username\&lt;/code>），替換為 &lt;code>[USER_HOME]/&lt;/code>。避免使用者名稱從 stack trace 洩漏。&lt;/p>
&lt;h2 id="自訂-redaction-rule">自訂 redaction rule&lt;/h2>
&lt;p>業務特定的敏感資訊（信用卡號、身分證字號、醫療資料）不在預設 rule 的範圍內。SDK 提供 API 讓開發者在 init 時註冊自訂 rule。&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">Monitor.init({
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> redactionRules: [
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> { pattern: /\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/, replace: &amp;#39;[CARD]&amp;#39; },
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> { keyPattern: /^ssn$/i, replace: &amp;#39;[REDACTED]&amp;#39; },
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> ],
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">})&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>自訂 rule 和預設 rule 一起執行。如果同一個值被多個 rule 匹配，第一個匹配的 rule 生效（rule 的執行順序：預設 rule 先，自訂 rule 後）。&lt;/p>
&lt;h2 id="redaction-的執行時機">Redaction 的執行時機&lt;/h2>
&lt;p>Redaction 在事件進入 flush payload 的那一刻執行 — buffer 中的事件保持原始內容，flush 時複製一份並在複製上執行 redaction。&lt;/p>
&lt;p>在 buffer 中保持原始內容的理由是 debug：開發者在本地 console 看到的 log 應該包含完整資訊（開發環境不需要脫敏），只有離開 SDK 時才脫敏。SDK 可以提供 &lt;code>debugMode&lt;/code> flag — debugMode 開啟時 console log 印出原始內容，HTTP POST 仍送出脫敏後的內容。&lt;/p>
&lt;h2 id="redaction-和模組七的關係">Redaction 和模組七的關係&lt;/h2>
&lt;p>SDK redaction helper 是&lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">模組七 資安與隱私&lt;/a>中 redaction 策略的實作層。模組七定義「什麼資訊需要被保護」（策略），本章定義「SDK 如何在程式碼中實現這個保護」（實作）。&lt;/p>
&lt;p>兩者的分工：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>層級&lt;/th>
 &lt;th>職責&lt;/th>
 &lt;th>定義在&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>策略層&lt;/td>
 &lt;td>哪些欄位需要 redaction、哪些 pattern 敏感&lt;/td>
 &lt;td>模組七&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>實作層&lt;/td>
 &lt;td>預設 rule、自訂 rule API、執行時機&lt;/td>
 &lt;td>本章&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>驗證層&lt;/td>
 &lt;td>確認脫敏後的事件不包含敏感資訊&lt;/td>
 &lt;td>collector 端&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Collector 端可以做第二道檢查（re-scan 收到的事件是否仍包含敏感 pattern），作為 SDK 端 redaction 的備援。但主要的脫敏責任在 SDK 端 — 資料離開 SDK 後經過網路，已經暴露在傳輸風險中。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>SDK 公開 API → &lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/public-api/" data-link-title="SDK 公開 API 設計" data-link-desc="init / event / error / metric / flush / close 六個方法構成 SDK 的完整生命週期 — 跨平台共用相同 API 介面">SDK 公開 API 設計&lt;/a>&lt;/li>
&lt;li>資安與隱私的完整策略 → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">模組七 資安與隱私&lt;/a>&lt;/li>
&lt;li>自動攔截的 error 也需要 redaction → &lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/auto-intercept/" data-link-title="自動攔截機制" data-link-desc="JS window.onerror / Flutter FlutterError.onError / Python sys.excepthook — 各平台攔截未捕獲例外的機制和限制">自動攔截機制&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>SDK <a href="/blog/monitoring/knowledge-cards/redaction/" data-link-title="Redaction" data-link-desc="說明在事件資料離開 client 之前把敏感欄位的值替換成遮罩或移除的機制">redaction</a> helper 在事件離開 SDK（進入 HTTP POST payload）前掃描事件內容，把匹配敏感資訊 pattern 的欄位值替換為 <code>[REDACTED]</code>。Redaction 在 SDK 端執行，確保敏感資訊不會經過網路傳輸到 collector — 即使 transport 層被攔截，攻擊者看到的也是脫敏後的資料。</p>
<h2 id="預設-redaction-rule">預設 redaction rule</h2>
<p>SDK 內建一組預設 rule，處理常見的敏感資訊 pattern：</p>
<h3 id="密碼欄位">密碼欄位</h3>
<p>匹配 data 物件中 key 包含 <code>password</code>、<code>passwd</code>、<code>secret</code>、<code>token</code>、<code>api_key</code>、<code>apiKey</code>、<code>authorization</code> 的欄位。匹配方式是 key 名稱的子字串比對（case-insensitive）。</p>
<h3 id="url-中的認證資訊">URL 中的認證資訊</h3>
<p>匹配 <code>https://user:password@host</code> 格式的 URL，把 <code>user:password</code> 部分替換為 <code>[REDACTED]</code>。</p>
<h3 id="stack-trace-中的檔案路徑">Stack trace 中的檔案路徑</h3>
<p>匹配 stack trace 字串中的使用者目錄路徑（<code>/Users/username/</code>、<code>/home/username/</code>、<code>C:\Users\username\</code>），替換為 <code>[USER_HOME]/</code>。避免使用者名稱從 stack trace 洩漏。</p>
<h2 id="自訂-redaction-rule">自訂 redaction rule</h2>
<p>業務特定的敏感資訊（信用卡號、身分證字號、醫療資料）不在預設 rule 的範圍內。SDK 提供 API 讓開發者在 init 時註冊自訂 rule。</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">Monitor.init({
</span></span><span class="line"><span class="ln">2</span><span class="cl">  redactionRules: [
</span></span><span class="line"><span class="ln">3</span><span class="cl">    { pattern: /\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/, replace: &#39;[CARD]&#39; },
</span></span><span class="line"><span class="ln">4</span><span class="cl">    { keyPattern: /^ssn$/i, replace: &#39;[REDACTED]&#39; },
</span></span><span class="line"><span class="ln">5</span><span class="cl">  ],
</span></span><span class="line"><span class="ln">6</span><span class="cl">})</span></span></code></pre></div><p>自訂 rule 和預設 rule 一起執行。如果同一個值被多個 rule 匹配，第一個匹配的 rule 生效（rule 的執行順序：預設 rule 先，自訂 rule 後）。</p>
<h2 id="redaction-的執行時機">Redaction 的執行時機</h2>
<p>Redaction 在事件進入 flush payload 的那一刻執行 — buffer 中的事件保持原始內容，flush 時複製一份並在複製上執行 redaction。</p>
<p>在 buffer 中保持原始內容的理由是 debug：開發者在本地 console 看到的 log 應該包含完整資訊（開發環境不需要脫敏），只有離開 SDK 時才脫敏。SDK 可以提供 <code>debugMode</code> flag — debugMode 開啟時 console log 印出原始內容，HTTP POST 仍送出脫敏後的內容。</p>
<h2 id="redaction-和模組七的關係">Redaction 和模組七的關係</h2>
<p>SDK redaction helper 是<a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">模組七 資安與隱私</a>中 redaction 策略的實作層。模組七定義「什麼資訊需要被保護」（策略），本章定義「SDK 如何在程式碼中實現這個保護」（實作）。</p>
<p>兩者的分工：</p>
<table>
  <thead>
      <tr>
          <th>層級</th>
          <th>職責</th>
          <th>定義在</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>策略層</td>
          <td>哪些欄位需要 redaction、哪些 pattern 敏感</td>
          <td>模組七</td>
      </tr>
      <tr>
          <td>實作層</td>
          <td>預設 rule、自訂 rule API、執行時機</td>
          <td>本章</td>
      </tr>
      <tr>
          <td>驗證層</td>
          <td>確認脫敏後的事件不包含敏感資訊</td>
          <td>collector 端</td>
      </tr>
  </tbody>
</table>
<p>Collector 端可以做第二道檢查（re-scan 收到的事件是否仍包含敏感 pattern），作為 SDK 端 redaction 的備援。但主要的脫敏責任在 SDK 端 — 資料離開 SDK 後經過網路，已經暴露在傳輸風險中。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>SDK 公開 API → <a href="/blog/monitoring/03-sdk-design/public-api/" data-link-title="SDK 公開 API 設計" data-link-desc="init / event / error / metric / flush / close 六個方法構成 SDK 的完整生命週期 — 跨平台共用相同 API 介面">SDK 公開 API 設計</a></li>
<li>資安與隱私的完整策略 → <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">模組七 資安與隱私</a></li>
<li>自動攔截的 error 也需要 redaction → <a href="/blog/monitoring/03-sdk-design/auto-intercept/" data-link-title="自動攔截機制" data-link-desc="JS window.onerror / Flutter FlutterError.onError / Python sys.excepthook — 各平台攔截未捕獲例外的機制和限制">自動攔截機制</a></li>
</ul>
]]></content:encoded></item><item><title>規模演進</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/</guid><description>&lt;p>Collector 的儲存方案是可插拔 storage backend — 同一個 binary 透過啟動參數選擇不同的 storage implementation。Go 的 interface composition 讓 storage 分成 BasicStorage（所有 backend 共用）和 AnalyticsStorage（PostgreSQL 層新增），內部實作（SQLite / PostgreSQL / 時間序列 DB）分離，切換是 config change 而非重寫程式碼。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">BasicStorage&lt;/span> &lt;span class="kd">interface&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nf">Store&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nf">Query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filter&lt;/span> &lt;span class="nx">QueryFilter&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">([]&lt;/span>&lt;span class="nx">Event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nf">Close&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nf">Downsample&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nf">Purge&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">AnalyticsStorage&lt;/span> &lt;span class="kd">interface&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">BasicStorage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nf">Aggregate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">spec&lt;/span> &lt;span class="nx">AggregateSpec&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">AggregateResult&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nf">Funnel&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">steps&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">timeWindow&lt;/span> &lt;span class="nx">Duration&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">FunnelResult&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nf">Cohort&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">groupBy&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">metric&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">CohortResult&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>SQLite implementation 只實作 &lt;code>BasicStorage&lt;/code>。PostgreSQL implementation 實作 &lt;code>AnalyticsStorage&lt;/code>。Dashboard 用 Go 的 type assertion（&lt;code>if as, ok := storage.(AnalyticsStorage); ok { ... }&lt;/code>）判斷能力 — funnel/cohort 視圖在 SQLite 模式下不顯示入口，而非顯示後報錯。&lt;/p>
&lt;p>選擇哪個 backend 取決於部署場景和查詢需求：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>場景&lt;/th>
 &lt;th>Backend&lt;/th>
 &lt;th>啟動參數&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>自架簡單版（零依賴）&lt;/td>
 &lt;td>SQLite&lt;/td>
 &lt;td>&lt;code>--storage=sqlite&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>需要聚合分析的自用版&lt;/td>
 &lt;td>PostgreSQL&lt;/td>
 &lt;td>&lt;code>--storage=postgres --dsn=...&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>高併發 + 長期保留&lt;/td>
 &lt;td>時間序列 DB&lt;/td>
 &lt;td>&lt;code>--storage=timescale --dsn=...&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="sqlite-backendday-one-預設">SQLite Backend（day-one 預設）&lt;/h2>
&lt;p>SQLite 是嵌入式資料庫，編譯進 collector binary 中，不需要額外 server。Go 用 &lt;code>modernc.org/sqlite&lt;/code>（pure Go、無 CGO 依賴、效能約為 CGO driver mattn/go-sqlite3 的 60-80%，自用規模下足夠），開源使用者 &lt;code>go build &amp;amp;&amp;amp; ./collector&lt;/code> 就能跑，部署步驟為零。WAL mode 允許讀寫並行 — dashboard 的 SELECT 查詢不會被 ingestion 的 INSERT 阻塞，反之亦然。寫入之間的競爭由 busy_timeout 處理。&lt;/p>
&lt;h3 id="能力範圍">能力範圍&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>索引查詢&lt;/strong>：按 type、name、timestamp 建索引，查詢從全表掃描變成索引查找&lt;/li>
&lt;li>&lt;strong>SQL 聚合&lt;/strong>：&lt;code>SELECT name, COUNT(*) FROM events WHERE type='error' GROUP BY name&lt;/code> — 一行 SQL 完成分群計數&lt;/li>
&lt;li>&lt;strong>跨欄位過濾&lt;/strong>：&lt;code>WHERE type='error' AND name LIKE 'terminal.%' AND ts &amp;gt; '2026-06-18'&lt;/code>&lt;/li>
&lt;li>&lt;strong>寫入&lt;/strong>：WAL mode 下每秒數千筆 append 寫入&lt;/li>
&lt;/ul>
&lt;h3 id="events-主表-ddl">Events 主表 DDL&lt;/h3>
&lt;p>Events 表的欄位從 &lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">event.schema.json&lt;/a> 的 JSON 結構推導。Source 的 nested object 攤平成獨立 column — 方便 SQL 查詢和索引，不需要每次從 JSON 裡 extract。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">events&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">AUTOINCREMENT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">type&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">source_sdk&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">source_app&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">source_version&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">source_platform&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">source_os&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">session_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">session_started&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">level&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">data&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">error_message&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">error_stack&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">error_type&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">receive_ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>source_sdk&lt;/code> 獨立成 column 讓「按 SDK 來源篩選」（&lt;code>WHERE source_sdk = 'python'&lt;/code>）不需要從 JSON extract。&lt;code>data&lt;/code> 用 TEXT 存 JSON。SQLite 沒有原生 JSON 型別，但 3.38+ 支援 &lt;code>json_extract()&lt;/code> 函式做查詢（&lt;code>WHERE json_extract(data, '$.duration_ms') &amp;gt; 1000&lt;/code>）。&lt;code>session_id&lt;/code> 獨立成 column 讓 session 回放的 JOIN 不需要 JSON extract。&lt;code>error_stack&lt;/code> 獨立成 column 讓 error 調查時全文搜尋 stack trace 不需要 JSON extract。&lt;code>receive_ts&lt;/code> 是 collector 收到事件的時間，和 SDK 端的 &lt;code>ts&lt;/code> 對照可估算 clock drift。&lt;/p></description><content:encoded><![CDATA[<p>Collector 的儲存方案是可插拔 storage backend — 同一個 binary 透過啟動參數選擇不同的 storage implementation。Go 的 interface composition 讓 storage 分成 BasicStorage（所有 backend 共用）和 AnalyticsStorage（PostgreSQL 層新增），內部實作（SQLite / PostgreSQL / 時間序列 DB）分離，切換是 config change 而非重寫程式碼。</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">type</span> <span class="nx">BasicStorage</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nf">Store</span><span class="p">(</span><span class="nx">event</span> <span class="nx">Event</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nf">Query</span><span class="p">(</span><span class="nx">filter</span> <span class="nx">QueryFilter</span><span class="p">)</span> <span class="p">([]</span><span class="nx">Event</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nf">Close</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nf">Downsample</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nf">Purge</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">AnalyticsStorage</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">BasicStorage</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nf">Aggregate</span><span class="p">(</span><span class="nx">spec</span> <span class="nx">AggregateSpec</span><span class="p">)</span> <span class="p">(</span><span class="nx">AggregateResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nf">Funnel</span><span class="p">(</span><span class="nx">steps</span> <span class="p">[]</span><span class="kt">string</span><span class="p">,</span> <span class="nx">timeWindow</span> <span class="nx">Duration</span><span class="p">)</span> <span class="p">(</span><span class="nx">FunnelResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nf">Cohort</span><span class="p">(</span><span class="nx">groupBy</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">metric</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">CohortResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>SQLite implementation 只實作 <code>BasicStorage</code>。PostgreSQL implementation 實作 <code>AnalyticsStorage</code>。Dashboard 用 Go 的 type assertion（<code>if as, ok := storage.(AnalyticsStorage); ok { ... }</code>）判斷能力 — funnel/cohort 視圖在 SQLite 模式下不顯示入口，而非顯示後報錯。</p>
<p>選擇哪個 backend 取決於部署場景和查詢需求：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>Backend</th>
          <th>啟動參數</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自架簡單版（零依賴）</td>
          <td>SQLite</td>
          <td><code>--storage=sqlite</code></td>
      </tr>
      <tr>
          <td>需要聚合分析的自用版</td>
          <td>PostgreSQL</td>
          <td><code>--storage=postgres --dsn=...</code></td>
      </tr>
      <tr>
          <td>高併發 + 長期保留</td>
          <td>時間序列 DB</td>
          <td><code>--storage=timescale --dsn=...</code></td>
      </tr>
  </tbody>
</table>
<h2 id="sqlite-backendday-one-預設">SQLite Backend（day-one 預設）</h2>
<p>SQLite 是嵌入式資料庫，編譯進 collector binary 中，不需要額外 server。Go 用 <code>modernc.org/sqlite</code>（pure Go、無 CGO 依賴、效能約為 CGO driver mattn/go-sqlite3 的 60-80%，自用規模下足夠），開源使用者 <code>go build &amp;&amp; ./collector</code> 就能跑，部署步驟為零。WAL mode 允許讀寫並行 — dashboard 的 SELECT 查詢不會被 ingestion 的 INSERT 阻塞，反之亦然。寫入之間的競爭由 busy_timeout 處理。</p>
<h3 id="能力範圍">能力範圍</h3>
<ul>
<li><strong>索引查詢</strong>：按 type、name、timestamp 建索引，查詢從全表掃描變成索引查找</li>
<li><strong>SQL 聚合</strong>：<code>SELECT name, COUNT(*) FROM events WHERE type='error' GROUP BY name</code> — 一行 SQL 完成分群計數</li>
<li><strong>跨欄位過濾</strong>：<code>WHERE type='error' AND name LIKE 'terminal.%' AND ts &gt; '2026-06-18'</code></li>
<li><strong>寫入</strong>：WAL mode 下每秒數千筆 append 寫入</li>
</ul>
<h3 id="events-主表-ddl">Events 主表 DDL</h3>
<p>Events 表的欄位從 <a href="/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">event.schema.json</a> 的 JSON 結構推導。Source 的 nested object 攤平成獨立 column — 方便 SQL 查詢和索引，不需要每次從 JSON 裡 extract。</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">CREATE</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="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">id</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="n">AUTOINCREMENT</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">v</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"> 4</span><span class="cl"><span class="w">    </span><span class="k">type</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"> 5</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"> 6</span><span class="cl"><span class="w">    </span><span class="n">ts</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"> 7</span><span class="cl"><span class="w">    </span><span class="n">source_sdk</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"> 8</span><span class="cl"><span class="w">    </span><span class="n">source_app</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"> 9</span><span class="cl"><span class="w">    </span><span class="n">source_version</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">10</span><span class="cl"><span class="w">    </span><span class="n">source_platform</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">11</span><span class="cl"><span class="w">    </span><span class="n">source_os</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">12</span><span class="cl"><span class="w">    </span><span class="n">session_id</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">13</span><span class="cl"><span class="w">    </span><span class="n">session_started</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">14</span><span class="cl"><span class="w">    </span><span class="k">level</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">15</span><span class="cl"><span class="w">    </span><span class="k">data</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">16</span><span class="cl"><span class="w">    </span><span class="n">error_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">17</span><span class="cl"><span class="w">    </span><span class="n">error_stack</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">18</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">19</span><span class="cl"><span class="w">    </span><span class="n">receive_ts</span><span class="w"> </span><span class="nb">TEXT</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w"></span><span class="p">);</span></span></span></code></pre></div><p><code>source_sdk</code> 獨立成 column 讓「按 SDK 來源篩選」（<code>WHERE source_sdk = 'python'</code>）不需要從 JSON extract。<code>data</code> 用 TEXT 存 JSON。SQLite 沒有原生 JSON 型別，但 3.38+ 支援 <code>json_extract()</code> 函式做查詢（<code>WHERE json_extract(data, '$.duration_ms') &gt; 1000</code>）。<code>session_id</code> 獨立成 column 讓 session 回放的 JOIN 不需要 JSON extract。<code>error_stack</code> 獨立成 column 讓 error 調查時全文搜尋 stack trace 不需要 JSON extract。<code>receive_ts</code> 是 collector 收到事件的時間，和 SDK 端的 <code>ts</code> 對照可估算 clock drift。</p>
<p>PostgreSQL 版本的差異：<code>data</code> 改成 <code>JSONB</code> 型別（原生索引和查詢）、<code>source_*</code> 可保持為 nested JSON（PostgreSQL 的 JSONB 查詢效能足夠）或維持攤平（和 SQLite 版本保持一致）。</p>
<h3 id="建議索引">建議索引</h3>
<p>建表時一起建索引，覆蓋 dashboard 的核心查詢模式：</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">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_type_ts</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="k">type</span><span class="p">,</span><span class="w"> </span><span class="n">ts</span><span class="p">);</span><span class="w">    </span><span class="c1">-- 按 type + 時間過濾（error 列表、趨勢圖）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_session</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">session_id</span><span class="p">);</span><span class="w">   </span><span class="c1">-- 按 session 回放
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_name</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">name</span><span class="p">);</span><span class="w">            </span><span class="c1">-- 按 name 分群計數（功能使用排行）</span></span></span></code></pre></div><p>Day-one 建表時就建，不是效能出問題後才加。</p>
<h3 id="適用規模">適用規模</h3>
<p>單日事件量在十萬筆以下、SQLite 資料庫在 1GB 以下。索引查詢在毫秒級完成。自用工具和小型團隊的日常使用通常在這個範圍。</p>
<h3 id="分層保留與降採樣">分層保留與降採樣</h3>
<p>保留策略從查詢需求反推，每一種查詢需要的資料粒度和回溯深度不同。回溯越深的查詢需要的粒度越粗 — debug 需要最近幾天的逐筆事件，cohort 留存需要一整年的資料但每週一筆聚合數字就夠。</p>
<table>
  <thead>
      <tr>
          <th>查詢用途</th>
          <th>需要的粒度</th>
          <th>回溯深度</th>
          <th>對應表</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Debug 定位</td>
          <td>逐筆原始</td>
          <td>天</td>
          <td>events</td>
      </tr>
      <tr>
          <td>Funnel</td>
          <td>逐筆 event</td>
          <td>週～月</td>
          <td>events</td>
      </tr>
      <tr>
          <td>Error 趨勢</td>
          <td>每小時計數</td>
          <td>月～季</td>
          <td>hourly_summary</td>
      </tr>
      <tr>
          <td>Cohort</td>
          <td>每天計數</td>
          <td>季～年</td>
          <td>daily_summary</td>
      </tr>
      <tr>
          <td>RFM 分群</td>
          <td>每月聚合</td>
          <td>年</td>
          <td>monthly_summary</td>
      </tr>
  </tbody>
</table>
<p>SQLite 中的實作是三張摘要表加定期 job：</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">-- 摘要表
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">hourly_summary</span><span class="w"> </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">hour</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="n">name</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"> 4</span><span class="cl"><span class="w">    </span><span class="k">count</span><span class="w"> </span><span class="nb">INTEGER</span><span class="p">,</span><span class="w"> </span><span class="n">error_count</span><span class="w"> </span><span class="nb">INTEGER</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="k">UNIQUE</span><span class="p">(</span><span class="n">hour</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="p">,</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"> 6</span><span class="cl"><span class="w"></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="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">daily_summary</span><span class="w"> </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="nb">date</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="n">name</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"> 9</span><span class="cl"><span class="w">    </span><span class="k">count</span><span class="w"> </span><span class="nb">INTEGER</span><span class="p">,</span><span class="w"> </span><span class="n">unique_sessions</span><span class="w"> </span><span class="nb">INTEGER</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="k">UNIQUE</span><span class="p">(</span><span class="nb">date</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="p">,</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">11</span><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w"></span><span class="c1">-- 降採樣（Downsample，每小時跑一次，幂等 — 重跑只更新不重複）
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">OR</span><span class="w"> </span><span class="k">REPLACE</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">hourly_summary</span><span class="w"> </span><span class="p">(</span><span class="n">hour</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="p">,</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="w"> </span><span class="n">error_count</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">SELECT</span><span class="w"> </span><span class="n">strftime</span><span class="p">(</span><span class="s1">&#39;%Y-%m-%dT%H:00:00&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">ts</span><span class="p">),</span><span class="w"> </span><span class="k">type</span><span class="p">,</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">16</span><span class="cl"><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">SUM</span><span class="p">(</span><span class="k">CASE</span><span class="w"> </span><span class="k">WHEN</span><span class="w"> </span><span class="k">type</span><span class="o">=</span><span class="s1">&#39;error&#39;</span><span class="w"> </span><span class="k">THEN</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">ELSE</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="k">END</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-1 hour&#39;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="mi">3</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w"></span><span class="c1">-- 清理（Purge，每天跑一次，分批刪除避免長時間鎖定）
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="c1"></span><span class="k">DELETE</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">rowid</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="n">rowid</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">ts</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-7 days&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">10000</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w"></span><span class="c1">-- 重複執行直到影響行數為 0
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="c1"></span><span class="k">DELETE</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">hourly_summary</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">hour</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-90 days&#39;</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="w"></span><span class="k">DELETE</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">daily_summary</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="nb">date</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-365 days&#39;</span><span class="p">);</span></span></span></code></pre></div><p>保留期限由 collector config 設定，數字的來源是「哪些查詢需要回溯多遠」：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">retention</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="nt">raw_events</span><span class="p">:</span><span class="w"> </span><span class="l">7d</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="nt">hourly_summary</span><span class="p">:</span><span class="w"> </span><span class="l">90d</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">  </span><span class="nt">daily_summary</span><span class="p">:</span><span class="w"> </span><span class="l">365d</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">  </span><span class="nt">monthly_summary</span><span class="p">:</span><span class="w"> </span><span class="l">forever</span></span></span></code></pre></div><p>Storage interface 的 <code>Downsample()</code> 和 <code>Purge()</code> 由 collector 的定時排程觸發（Go 的 <code>time.Ticker</code>）。每個 storage backend 各自實作 — SQLite 用上述 SQL、PostgreSQL 用相同邏輯但可以加 partial index 加速、時間序列 DB 的 continuous aggregate 和 retention policy 原生支援。</p>
<h3 id="為什麼是聚合而非抽樣">為什麼是聚合而非抽樣</h3>
<p>原始事件的保留期到期後，需要決定如何保留歷史統計。降採樣有兩種思路。<strong>抽樣保留</strong>是同事件名稱（<code>name</code> 欄位）同小時保留一筆原始事件、刪除其餘，保留了逐筆查詢能力但喪失準確計數。<strong>聚合摘要</strong>是把一小時內的事件壓成一筆計數記錄，喪失逐筆細節但保留準確統計。</p>
<p>Collector 選擇聚合摘要——捨棄逐筆細節，換取準確計數。降採樣後的資料用途是趨勢圖和長期統計，這些查詢需要「過去 30 天每小時的 error 總數」而非「某一筆原始 error 的 stack trace」。</p>
<p>這意味著原始事件 purge（定期清理過期事件）後，超過保留期的逐筆查詢會回傳空結果。Dashboard 在回溯超過原始事件保留期的時間範圍時，應切換到上方的摘要表（<code>hourly_summary</code>/<code>daily_summary</code>）查詢——顯示趨勢圖而非事件列表。設計方向是查詢 API 的 <code>from</code> 參數超過 <code>retention.raw_events</code> 時自動降級到摘要表，或回傳提示告知 client 該時間範圍只有聚合資料（初版 collector 尚未實作此降級邏輯）。</p>
<h3 id="觸發切換到-postgresql-的訊號">觸發切換到 PostgreSQL 的訊號</h3>
<p><strong>寫入爭搶</strong>：SQLite 是單寫者模型。高併發寫入（多個 SDK 同時 flush、每秒數百筆以上持續發生）會出現 <code>database is locked</code> 錯誤。WAL mode 能緩解但不能根治。</p>
<p><strong>聚合查詢效能不足</strong>：Dashboard 需要的聚合查詢（「過去 30 天每小時的 error 數量趨勢」「funnel 的每步轉換率」）在資料量成長後變慢。SQLite 沒有 parallel query 和 partial index 等進階 OLAP 能力。</p>
<p><strong>跨實例需求</strong>：需要多個 collector 實例共用同一個資料庫時，SQLite 的單檔案模型無法跨主機存取。</p>
<h2 id="postgresql-backend分析觸發">PostgreSQL Backend（分析觸發）</h2>
<p>PostgreSQL 是獨立的資料庫 server，提供多連線並行寫入、進階索引（GIN for JSONB、partial index）和完整的 SQL 分析能力。切換到 PostgreSQL 意味著 collector 從「零依賴單一 binary」變成「binary + 外部 DB」，運維複雜度上升。</p>
<h3 id="觸發條件">觸發條件</h3>
<p>SQLite 的寫入爭搶或聚合效能成為瓶頸時切換。具體訊號：<code>database is locked</code> 錯誤頻率超過每分鐘一次、或 dashboard 的聚合查詢超過 3 秒。</p>
<h3 id="切換方式">切換方式</h3>
<p>切換是 config change：把 <code>--storage=sqlite</code> 改成 <code>--storage=postgres --dsn=postgres://...</code>。資料遷移用匯出 + 匯入完成：</p>
<ol>
<li>從 SQLite 匯出事件為 JSONL（<code>monitor export --format=jsonl</code>）</li>
<li>在 PostgreSQL 建立 events 表（schema 和 SQLite 相同，data 欄位改用 JSONB）</li>
<li>匯入 JSONL 到 PostgreSQL（<code>monitor import --storage=postgres --file=events.jsonl</code>）</li>
<li>切換啟動參數、確認查詢正常後停用 SQLite 檔案</li>
</ol>
<p>Storage interface 保證 collector 的 ingestion、query、rule engine 邏輯不需要改動 — 只有 storage implementation 層切換。</p>
<h3 id="能力增量">能力增量</h3>
<ul>
<li><strong>並行寫入</strong>：多個 SDK 同時 flush 不會 lock</li>
<li><strong>JSONB 索引</strong>：對 data 欄位的特定 key 建索引（<code>CREATE INDEX ON events ((data-&gt;&gt;'name'))</code>）</li>
<li><strong>Window function</strong>：funnel 和 cohort 分析的 SQL 基礎</li>
<li><strong>Read replica</strong>：寫入和查詢分離，dashboard 的查詢不影響 ingestion 效能</li>
</ul>
<h2 id="時間序列-db-backend長期演進">時間序列 DB Backend（長期演進）</h2>
<p>時間序列資料庫（TimescaleDB、InfluxDB、VictoriaMetrics）專門為高頻 append 寫入和時間分桶聚合設計。TimescaleDB 基於 PostgreSQL 擴展，Storage interface 的 PostgreSQL implementation 可以直接複用、加上 hypertable 和 continuous aggregate。</p>
<h3 id="觸發條件-1">觸發條件</h3>
<p>每秒數萬筆以上的持續寫入、或需要自動 downsampling（每分鐘的原始資料保留 7 天、每小時的聚合保留 90 天、每天的聚合永久保留）。多數自用工具和小型團隊不會到達這個規模。</p>
<h3 id="能力增量-1">能力增量</h3>
<ul>
<li><strong>時間分桶原生操作</strong>：<code>time_bucket('1 hour', ts)</code> 替代手動 DATE_TRUNC</li>
<li><strong>Continuous aggregate</strong>：預計算的聚合結果自動更新</li>
<li><strong>壓縮</strong>：歷史資料自動壓縮，TB 級資料可查詢</li>
<li><strong>Retention policy</strong>：按時間自動清理舊資料</li>
</ul>
<h2 id="jsonl-匯出debug-用途">JSONL 匯出（debug 用途）</h2>
<p>JSONL 不作為主要 storage backend，而是作為匯出格式保留人類可讀性和 grep 友好性。<code>monitor export --format=jsonl</code> 把 storage 中的事件匯出為每行一個 JSON 物件的檔案，讓開發者可以用 grep / jq 做臨時查詢或把資料搬到其他工具。</p>
<p>JSONL 匯出也是備份和遷移的中介格式 — SQLite 損壞時從 JSONL 重建、切換到 PostgreSQL 時從 JSONL 匯入。</p>
<p>匯出使用 streaming — 從 storage 逐筆讀取、逐行寫出檔案，記憶體使用和事件總量無關。300 萬筆事件（約 900MB JSONL）的匯出不需要載入全部資料到記憶體。匯出的 JSONL 檔案包含事件明文（已 redaction 的欄位除外），匯出後不受 collector 的存取控制保護，應注意存放位置和存取權限。</p>
<h2 id="演進原則">演進原則</h2>
<p><strong>按觀察到的瓶頸切換</strong>。<code>database is locked</code> 錯誤頻率、聚合查詢延遲、磁碟使用量 — 這些是可觀察的訊號。「未來可能有百萬筆事件」是預測。按訊號行動，不按預測行動。</p>
<p><strong>切換是 config change</strong>。Storage interface 確保切換 backend 時 collector 的其他邏輯（ingestion、query API、rule engine、dashboard）不需要改動。切換的成本是資料遷移，不是程式碼重寫。</p>
<p><strong>SQLite 是安全的起點</strong>。多數開源使用者會停留在 SQLite backend — 單日萬筆以下、索引查詢毫秒級、零依賴部署。只有明確的效能瓶頸才值得引入外部 DB 的運維成本。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<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>查詢 API 的設計（跨 backend 統一） → <a href="/blog/monitoring/04-collector/query-api/" data-link-title="查詢 API 設計" data-link-desc="CLI grep 友好的 JSONL 結構 &#43; HTTP 查詢 endpoint — 兩種查詢介面各自的適用場景和設計要點">查詢 API 設計</a></li>
<li>資料庫選型的通用指南 → <a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">backend 01 資料庫</a></li>
<li>效能瓶頸的判讀方法 → <a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">backend 09 效能容量</a></li>
<li>水平擴展的基礎概念 → <a href="/blog/devops/02-horizontal-scaling/" data-link-title="模組二：水平擴展" data-link-desc="一個實例不夠時怎麼加第二個 — stateless 設計、shared storage、session 處理的工程約束">DevOps 水平擴展</a></li>
<li>Error fingerprint 的 DDL 擴充 → <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></li>
</ul>
]]></content:encoded></item><item><title>跨平台 timestamp 一致性</title><link>https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/cross-platform-timestamp/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/cross-platform-timestamp/</guid><description>&lt;p>跨平台的監控系統收到來自不同平台（JS / Flutter / Python / Go）的事件，每個平台的 timestamp 格式、精度和時鐘來源不同。Collector 需要對這些 timestamp 做排序、分組和時間範圍查詢，一致性問題會導致事件順序錯亂和分析結果偏差。&lt;/p>
&lt;h2 id="統一格式iso-8601--時區偏移">統一格式：ISO 8601 + 時區偏移&lt;/h2>
&lt;p>所有平台的 SDK 統一使用 ISO 8601 格式，包含毫秒精度和時區偏移：&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">2026-06-19T14:30:00.123+08:00&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>避免使用 Unix timestamp（秒或毫秒）作為僅有的時間表示 — Unix timestamp 沒有時區資訊，如果 SDK 端和 collector 端在不同時區，需要額外的 metadata 才能正確轉換。&lt;/p>
&lt;p>避免使用「本地時間不帶時區」的格式（&lt;code>2026-06-19T14:30:00&lt;/code>）— 無法區分 UTC+8 的 14:30 和 UTC+0 的 14:30。&lt;/p>
&lt;h2 id="各平台的-timestamp-來源">各平台的 timestamp 來源&lt;/h2>
&lt;h3 id="javascript">JavaScript&lt;/h3>
&lt;p>&lt;code>Date.now()&lt;/code> 回傳毫秒精度的 Unix timestamp。&lt;code>new Date().toISOString()&lt;/code> 回傳 UTC 時間的 ISO 8601 字串。&lt;/p>
&lt;p>SDK 應該用 &lt;code>Intl.DateTimeFormat&lt;/code> 或手動計算時區偏移，產生帶本地時區的 ISO 8601 字串 — collector 端需要知道事件的本地時間，以便做使用者時區的分析。&lt;/p>
&lt;p>&lt;code>performance.now()&lt;/code> 提供微秒精度的高解析度時間，但起點是頁面載入時間，無法用來產生絕對 timestamp。用於計算 duration（兩個時間點的差值），不用於記錄事件時間。&lt;/p>
&lt;h3 id="flutter--dart">Flutter / Dart&lt;/h3>
&lt;p>&lt;code>DateTime.now()&lt;/code> 回傳本地時間的 DateTime 物件。&lt;code>DateTime.now().toUtc()&lt;/code> 轉成 UTC。&lt;code>DateTime.now().toIso8601String()&lt;/code> 產生 ISO 8601 字串，但不包含時區偏移（Dart 的 ISO 8601 格式不包含 offset）。&lt;/p>
&lt;p>SDK 需要手動附加時區偏移：&lt;code>DateTime.now().timeZoneOffset&lt;/code> 取得偏移量，手動格式化為 &lt;code>+08:00&lt;/code> 格式附加到 ISO 8601 字串後面。&lt;/p>
&lt;h3 id="python">Python&lt;/h3>
&lt;p>&lt;code>datetime.now(timezone.utc)&lt;/code> 取得 UTC 時間。&lt;code>datetime.now().astimezone()&lt;/code> 取得本地時間帶時區。&lt;code>.isoformat()&lt;/code> 產生帶時區偏移的 ISO 8601 字串。&lt;/p>
&lt;p>Python 3.2+ 的 &lt;code>datetime&lt;/code> 原生支援 timezone-aware 的 ISO 8601 輸出，是各平台中最完整的。&lt;/p>
&lt;h3 id="go">Go&lt;/h3>
&lt;p>&lt;code>time.Now()&lt;/code> 回傳帶時區的 Time 值。&lt;code>time.Now().Format(time.RFC3339Milli)&lt;/code> 產生帶毫秒和時區偏移的字串。&lt;/p>
&lt;p>Go 的 &lt;code>time.RFC3339Nano&lt;/code> 提供奈秒精度，但監控事件不需要這個精度 — 毫秒足夠。&lt;/p>
&lt;h2 id="clock-drift">Clock drift&lt;/h2>
&lt;p>不同裝置的系統時鐘可能有偏差（clock drift）。使用者手機的時鐘比 collector server 快 5 分鐘，SDK 產生的 timestamp 會比 collector 收到時間早 5 分鐘。&lt;/p>
&lt;p>Clock drift 的影響：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>排序錯亂&lt;/strong>：裝置 A（時鐘快）和裝置 B（時鐘慢）的事件混合排序時，時間順序可能和真實發生順序不一致&lt;/li>
&lt;li>&lt;strong>告警延遲計算錯誤&lt;/strong>：collector 用「事件 timestamp 到收到時間的差值」計算延遲，clock drift 讓延遲值不準確&lt;/li>
&lt;/ul>
&lt;p>處理策略：&lt;/p>
&lt;p>&lt;strong>Collector 記錄 receive_timestamp&lt;/strong>：每筆事件除了 SDK 端的 timestamp，collector 在收到時附加 &lt;code>receive_timestamp&lt;/code>。兩者的差值用於估算 clock drift 和網路延遲。&lt;/p>
&lt;p>&lt;strong>容忍而非修正&lt;/strong>：在數秒到數分鐘級的 drift 範圍內，容忍 drift 帶來的排序不精確。跨裝置的事件排序本身就不需要毫秒精度 — 分析的粒度通常是秒或分鐘。&lt;/p>
&lt;p>&lt;strong>異常值偵測&lt;/strong>：timestamp 比 receive_timestamp 早超過 1 小時，或晚超過 5 分鐘，標記為可疑的 clock drift — 可能是使用者手動調整了系統時鐘。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>JS 平台適配 → &lt;a href="https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/js-ts-platform/" data-link-title="JS/TS 平台適配" data-link-desc="CORS 限制、Service Worker 攔截、SPA 路由變換偵測 — 瀏覽器環境中 SDK 需要處理的平台特殊問題">JS/TS 平台適配&lt;/a>&lt;/li>
&lt;li>Flutter 平台適配 → &lt;a href="https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/flutter-platform/" data-link-title="Flutter 平台適配" data-link-desc="Isolate 安全、Platform channel 攔截、app lifecycle 事件 — Flutter SDK 的平台特殊考量">Flutter 平台適配&lt;/a>&lt;/li>
&lt;li>Log schema 中的 timestamp 欄位 → &lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">模組二 event.schema.json 欄位解說&lt;/a>&lt;/li>
&lt;li>各平台的 error 攔截差異影響 test 設計 → &lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/" data-link-title="模組五：測試設計判斷" data-link-desc="Mock 邊界判斷、assertion 設計、test data 代表性、flaky test 診斷">testing 模組五 測試設計判斷&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>跨平台的監控系統收到來自不同平台（JS / Flutter / Python / Go）的事件，每個平台的 timestamp 格式、精度和時鐘來源不同。Collector 需要對這些 timestamp 做排序、分組和時間範圍查詢，一致性問題會導致事件順序錯亂和分析結果偏差。</p>
<h2 id="統一格式iso-8601--時區偏移">統一格式：ISO 8601 + 時區偏移</h2>
<p>所有平台的 SDK 統一使用 ISO 8601 格式，包含毫秒精度和時區偏移：</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">2026-06-19T14:30:00.123+08:00</span></span></code></pre></div><p>避免使用 Unix timestamp（秒或毫秒）作為僅有的時間表示 — Unix timestamp 沒有時區資訊，如果 SDK 端和 collector 端在不同時區，需要額外的 metadata 才能正確轉換。</p>
<p>避免使用「本地時間不帶時區」的格式（<code>2026-06-19T14:30:00</code>）— 無法區分 UTC+8 的 14:30 和 UTC+0 的 14:30。</p>
<h2 id="各平台的-timestamp-來源">各平台的 timestamp 來源</h2>
<h3 id="javascript">JavaScript</h3>
<p><code>Date.now()</code> 回傳毫秒精度的 Unix timestamp。<code>new Date().toISOString()</code> 回傳 UTC 時間的 ISO 8601 字串。</p>
<p>SDK 應該用 <code>Intl.DateTimeFormat</code> 或手動計算時區偏移，產生帶本地時區的 ISO 8601 字串 — collector 端需要知道事件的本地時間，以便做使用者時區的分析。</p>
<p><code>performance.now()</code> 提供微秒精度的高解析度時間，但起點是頁面載入時間，無法用來產生絕對 timestamp。用於計算 duration（兩個時間點的差值），不用於記錄事件時間。</p>
<h3 id="flutter--dart">Flutter / Dart</h3>
<p><code>DateTime.now()</code> 回傳本地時間的 DateTime 物件。<code>DateTime.now().toUtc()</code> 轉成 UTC。<code>DateTime.now().toIso8601String()</code> 產生 ISO 8601 字串，但不包含時區偏移（Dart 的 ISO 8601 格式不包含 offset）。</p>
<p>SDK 需要手動附加時區偏移：<code>DateTime.now().timeZoneOffset</code> 取得偏移量，手動格式化為 <code>+08:00</code> 格式附加到 ISO 8601 字串後面。</p>
<h3 id="python">Python</h3>
<p><code>datetime.now(timezone.utc)</code> 取得 UTC 時間。<code>datetime.now().astimezone()</code> 取得本地時間帶時區。<code>.isoformat()</code> 產生帶時區偏移的 ISO 8601 字串。</p>
<p>Python 3.2+ 的 <code>datetime</code> 原生支援 timezone-aware 的 ISO 8601 輸出，是各平台中最完整的。</p>
<h3 id="go">Go</h3>
<p><code>time.Now()</code> 回傳帶時區的 Time 值。<code>time.Now().Format(time.RFC3339Milli)</code> 產生帶毫秒和時區偏移的字串。</p>
<p>Go 的 <code>time.RFC3339Nano</code> 提供奈秒精度，但監控事件不需要這個精度 — 毫秒足夠。</p>
<h2 id="clock-drift">Clock drift</h2>
<p>不同裝置的系統時鐘可能有偏差（clock drift）。使用者手機的時鐘比 collector server 快 5 分鐘，SDK 產生的 timestamp 會比 collector 收到時間早 5 分鐘。</p>
<p>Clock drift 的影響：</p>
<ul>
<li><strong>排序錯亂</strong>：裝置 A（時鐘快）和裝置 B（時鐘慢）的事件混合排序時，時間順序可能和真實發生順序不一致</li>
<li><strong>告警延遲計算錯誤</strong>：collector 用「事件 timestamp 到收到時間的差值」計算延遲，clock drift 讓延遲值不準確</li>
</ul>
<p>處理策略：</p>
<p><strong>Collector 記錄 receive_timestamp</strong>：每筆事件除了 SDK 端的 timestamp，collector 在收到時附加 <code>receive_timestamp</code>。兩者的差值用於估算 clock drift 和網路延遲。</p>
<p><strong>容忍而非修正</strong>：在數秒到數分鐘級的 drift 範圍內，容忍 drift 帶來的排序不精確。跨裝置的事件排序本身就不需要毫秒精度 — 分析的粒度通常是秒或分鐘。</p>
<p><strong>異常值偵測</strong>：timestamp 比 receive_timestamp 早超過 1 小時，或晚超過 5 分鐘，標記為可疑的 clock drift — 可能是使用者手動調整了系統時鐘。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>JS 平台適配 → <a href="/blog/monitoring/05-platform-adaptation/js-ts-platform/" data-link-title="JS/TS 平台適配" data-link-desc="CORS 限制、Service Worker 攔截、SPA 路由變換偵測 — 瀏覽器環境中 SDK 需要處理的平台特殊問題">JS/TS 平台適配</a></li>
<li>Flutter 平台適配 → <a href="/blog/monitoring/05-platform-adaptation/flutter-platform/" data-link-title="Flutter 平台適配" data-link-desc="Isolate 安全、Platform channel 攔截、app lifecycle 事件 — Flutter SDK 的平台特殊考量">Flutter 平台適配</a></li>
<li>Log schema 中的 timestamp 欄位 → <a href="/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">模組二 event.schema.json 欄位解說</a></li>
<li>各平台的 error 攔截差異影響 test 設計 → <a href="/blog/testing/05-test-design-judgment/" data-link-title="模組五：測試設計判斷" data-link-desc="Mock 邊界判斷、assertion 設計、test data 代表性、flaky test 診斷">testing 模組五 測試設計判斷</a></li>
</ul>
]]></content:encoded></item><item><title>模組五：平台適配</title><link>https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/</guid><description>&lt;p>回答「各平台有什麼特殊考量」。&lt;/p>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> JS/TS 平台：CORS 限制、Service Worker 攔截、SPA 路由變換偵測&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Flutter 平台：isolate 安全、Platform channel 攔截、app lifecycle&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Python 平台：GIL 與 threading、atexit 可靠性、subprocess 監控&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Go 平台：graceful shutdown、signal handling、HTTP server 自身監控&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 跨平台 timestamp 一致性（時區、精度、clock drift）&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/" data-link-title="模組五：測試設計判斷" data-link-desc="Mock 邊界判斷、assertion 設計、test data 代表性、flaky test 診斷">testing 模組五 測試設計判斷&lt;/a>：各平台 error 攔截差異影響 test 設計&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「各平台有什麼特殊考量」。</p>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> JS/TS 平台：CORS 限制、Service Worker 攔截、SPA 路由變換偵測</li>
<li><input checked="" disabled="" type="checkbox"> Flutter 平台：isolate 安全、Platform channel 攔截、app lifecycle</li>
<li><input checked="" disabled="" type="checkbox"> Python 平台：GIL 與 threading、atexit 可靠性、subprocess 監控</li>
<li><input checked="" disabled="" type="checkbox"> Go 平台：graceful shutdown、signal handling、HTTP server 自身監控</li>
<li><input checked="" disabled="" type="checkbox"> 跨平台 timestamp 一致性（時區、精度、clock drift）</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/testing/05-test-design-judgment/" data-link-title="模組五：測試設計判斷" data-link-desc="Mock 邊界判斷、assertion 設計、test data 代表性、flaky test 診斷">testing 模組五 測試設計判斷</a>：各平台 error 攔截差異影響 test 設計</li>
</ul>
]]></content:encoded></item><item><title>Backpressure</title><link>https://tarrragon.github.io/blog/monitoring/knowledge-cards/backpressure/</link><pubDate>Wed, 24 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/knowledge-cards/backpressure/</guid><description>&lt;p>背壓（backpressure）的通用概念見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">Backend 知識卡：Backpressure&lt;/a> — 下游處理能力不足時向上游回傳「慢下來」訊號。本卡聚焦監控系統中的具體實作：collector 是下游、SDK 是上游，collector 的寫入 channel 滿時回 HTTP 429（Too Many Requests），SDK 收到 429 後自動降低&lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/sampling/" data-link-title="Sampling" data-link-desc="在事件產生階段按比例丟棄部分事件降低管線負載 — 分靜態取樣（config 固定比例）和動態取樣（背壓觸發自動降低）">取樣&lt;/a>率。可先對照 &lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/rate-limiting/" data-link-title="Rate Limiting" data-link-desc="限制每個 client 在單位時間內可送出的事件數量 — 防止單一 SDK bug 或偽造流量消耗整個 collector 的處理能力">rate limiting&lt;/a>（per-client 的配額限制）。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>背壓位在 SDK 和 collector 之間的 HTTP 通訊層。觸發順序：collector 的寫入 channel 容量耗盡 → HTTP handler 無法送入事件 → 回 429 → SDK 收到 429 → SDK 降低取樣率（從 1.0 → 0.5 → 0.1）。背壓是全域的容量訊號 — 所有 SDK 同時收到，所有 SDK 同時降速。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>需要關注背壓的訊號是 collector 端的 &lt;code>collector.events.backpressure&lt;/code> 計數器持續上升、或 SDK 端的 &lt;code>sdk.sampling.rate&lt;/code> 低於 1.0。典型場景：行銷活動導致同時在線使用者暴增 → 所有 SDK 同時 flush → collector channel 瞬間填滿 → 全域 429 → 所有 SDK 動態降採樣。&lt;/p>
&lt;h2 id="和-devops-背壓的關係">和 DevOps 背壓的關係&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">DevOps 流量管控&lt;/a>討論通用的背壓概念（TCP flow control、message queue consumer lag、circuit breaker）。本系列聚焦 SDK ↔ collector 之間的具體實作 — HTTP 429 是訊號、動態取樣是回應、Go channel 容量是觸發條件。通用概念在 DevOps 模組，監控場景的具體機制在本系列。&lt;/p>
&lt;h2 id="完整章節">完整章節&lt;/h2>
&lt;p>背壓在四層防線中的位置（第二層 collector 單機防護）→ &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling&lt;/a>。背壓造成的資料損失和控制策略 → &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>背壓（backpressure）的通用概念見 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">Backend 知識卡：Backpressure</a> — 下游處理能力不足時向上游回傳「慢下來」訊號。本卡聚焦監控系統中的具體實作：collector 是下游、SDK 是上游，collector 的寫入 channel 滿時回 HTTP 429（Too Many Requests），SDK 收到 429 後自動降低<a href="/blog/monitoring/knowledge-cards/sampling/" data-link-title="Sampling" data-link-desc="在事件產生階段按比例丟棄部分事件降低管線負載 — 分靜態取樣（config 固定比例）和動態取樣（背壓觸發自動降低）">取樣</a>率。可先對照 <a href="/blog/monitoring/knowledge-cards/rate-limiting/" data-link-title="Rate Limiting" data-link-desc="限制每個 client 在單位時間內可送出的事件數量 — 防止單一 SDK bug 或偽造流量消耗整個 collector 的處理能力">rate limiting</a>（per-client 的配額限制）。</p>
<h2 id="概念位置">概念位置</h2>
<p>背壓位在 SDK 和 collector 之間的 HTTP 通訊層。觸發順序：collector 的寫入 channel 容量耗盡 → HTTP handler 無法送入事件 → 回 429 → SDK 收到 429 → SDK 降低取樣率（從 1.0 → 0.5 → 0.1）。背壓是全域的容量訊號 — 所有 SDK 同時收到，所有 SDK 同時降速。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>需要關注背壓的訊號是 collector 端的 <code>collector.events.backpressure</code> 計數器持續上升、或 SDK 端的 <code>sdk.sampling.rate</code> 低於 1.0。典型場景：行銷活動導致同時在線使用者暴增 → 所有 SDK 同時 flush → collector channel 瞬間填滿 → 全域 429 → 所有 SDK 動態降採樣。</p>
<h2 id="和-devops-背壓的關係">和 DevOps 背壓的關係</h2>
<p><a href="/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">DevOps 流量管控</a>討論通用的背壓概念（TCP flow control、message queue consumer lag、circuit breaker）。本系列聚焦 SDK ↔ collector 之間的具體實作 — HTTP 429 是訊號、動態取樣是回應、Go channel 容量是觸發條件。通用概念在 DevOps 模組，監控場景的具體機制在本系列。</p>
<h2 id="完整章節">完整章節</h2>
<p>背壓在四層防線中的位置（第二層 collector 單機防護）→ <a href="/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling</a>。背壓造成的資料損失和控制策略 → <a href="/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性</a>。</p>
]]></content:encoded></item><item><title>功能分層與 Backend 選擇</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/feature-tier-boundary/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/feature-tier-boundary/</guid><description>&lt;p>Collector 的可插拔 Storage Backend 分成兩個功能層級。分界線是查詢模式 — SQLite 能高效處理的查詢定義了簡單版的功能邊界，超出的查詢需求觸發 PostgreSQL 的引入。所有事件都經過同一個 Ingestion domain，差異在 Query 和 Dashboard domain 能提供什麼能力。&lt;/p>
&lt;h2 id="sqlite-層開發者工具">SQLite 層：開發者工具&lt;/h2>
&lt;p>SQLite 層提供的功能聚焦在「開發者自己 debug 和監控」。所有查詢都是單一維度的 — 按時間、按類型、按名稱過濾，不需要跨事件 JOIN 或跨使用者聚合。&lt;/p>
&lt;h3 id="承載的功能">承載的功能&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>功能&lt;/th>
 &lt;th>查詢模式&lt;/th>
 &lt;th>SQL 範例&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>最近 error 列表&lt;/td>
 &lt;td>按 type + 時間過濾&lt;/td>
 &lt;td>&lt;code>WHERE type='error' ORDER BY ts DESC LIMIT 20&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Error 計數（按 name 分群）&lt;/td>
 &lt;td>單表 GROUP BY&lt;/td>
 &lt;td>&lt;code>SELECT name, COUNT(*) FROM events WHERE type='error' GROUP BY name&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>單次 session 回放&lt;/td>
 &lt;td>按 session_id 過濾&lt;/td>
 &lt;td>&lt;code>WHERE session_id='xxx' ORDER BY ts&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事件時間軸&lt;/td>
 &lt;td>按時間排序&lt;/td>
 &lt;td>&lt;code>WHERE ts BETWEEN ? AND ? ORDER BY ts&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>基本 rule engine&lt;/td>
 &lt;td>逐筆事件評估&lt;/td>
 &lt;td>收到事件時逐條比對 rule（不需要查歷史）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CLI 查詢&lt;/td>
 &lt;td>任意過濾&lt;/td>
 &lt;td>&lt;code>WHERE type=? AND name LIKE ? AND ts &amp;gt; ?&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這些功能覆蓋開發者日常 debug 和監控的核心操作 — 查錯誤、看時間軸、回放 session、設規則告警。&lt;/p>
&lt;h3 id="對應的-dashboard-視圖">對應的 Dashboard 視圖&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>視圖&lt;/th>
 &lt;th>顯示&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>總覽頁&lt;/td>
 &lt;td>最近 1 小時的事件計數（按 type 分）+ 最近 error 列表&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事件詳情&lt;/td>
 &lt;td>單筆事件的完整 JSON&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Session 回放&lt;/td>
 &lt;td>單次 session 內的事件序列&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="對應的事件消費">對應的事件消費&lt;/h3>
&lt;p>SQLite 層消費所有四類事件，但消費方式是「單筆或單 session 級查詢」：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>事件類型&lt;/th>
 &lt;th>消費方式&lt;/th>
 &lt;th>保留需求&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>event&lt;/td>
 &lt;td>按名稱計數、按 session 排列&lt;/td>
 &lt;td>原始 7 天（debug）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>error&lt;/td>
 &lt;td>按名稱分群、按時間排列、看 stack trace&lt;/td>
 &lt;td>原始 30 天（error 追蹤價值較長）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>metric&lt;/td>
 &lt;td>按名稱查最近 N 筆的值&lt;/td>
 &lt;td>原始 7 天 + 每小時聚合 90 天&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>lifecycle&lt;/td>
 &lt;td>按 session 排列、看狀態轉換&lt;/td>
 &lt;td>原始 7 天&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="postgresql-層行為分析">PostgreSQL 層：行為分析&lt;/h2>
&lt;p>PostgreSQL 層在 SQLite 層的基礎上加入「跨 session、跨使用者的聚合分析」。這些查詢需要 JOIN 多張表、計算時間窗口、處理大量資料的 GROUP BY — SQLite 的單寫者模型和有限的查詢最佳化器在這些場景下效能不足。&lt;/p>
&lt;h3 id="觸發引入-postgresql-的功能需求">觸發引入 PostgreSQL 的功能需求&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>功能需求&lt;/th>
 &lt;th>為什麼 SQLite 不夠&lt;/th>
 &lt;th>PostgreSQL 提供什麼&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>Funnel 分析&lt;/strong>&lt;/td>
 &lt;td>跨大量 session 的 multi-step JOIN 和聚合效能不足&lt;/td>
 &lt;td>Window functions + 高效 JOIN&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Cohort 留存&lt;/strong>&lt;/td>
 &lt;td>需要按「註冊週」分群、計算每週的回訪率&lt;/td>
 &lt;td>Date functions + 大規模 GROUP BY&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>RFM 分群&lt;/strong>&lt;/td>
 &lt;td>需要跨所有使用者計算 recency/frequency/monetary&lt;/td>
 &lt;td>全表聚合 + 分位數計算&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>時間趨勢 dashboard&lt;/strong>&lt;/td>
 &lt;td>需要「過去 30 天每小時的 error P95」&lt;/td>
 &lt;td>時間分桶 + percentile 函數&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>高併發寫入&lt;/strong>&lt;/td>
 &lt;td>多個 SDK 同時 flush 且持續出現 database is locked&lt;/td>
 &lt;td>連線池 + 並行寫入&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>長期保留 + 聚合&lt;/strong>&lt;/td>
 &lt;td>降採樣的 materialized view&lt;/td>
 &lt;td>REFRESH MATERIALIZED VIEW&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="判斷公式">判斷公式&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">需要 funnel / cohort / RFM 任一 → PostgreSQL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">需要跨使用者聚合（不只看自己的資料） → PostgreSQL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">需要高併發寫入（多個 SDK 同時 flush 且持續出現 database is locked 錯誤） → PostgreSQL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">以上都不需要 → SQLite 足夠&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="對應的-dashboard-視圖sqlite-層不提供">對應的 Dashboard 視圖（SQLite 層不提供）&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>視圖&lt;/th>
 &lt;th>查詢模式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Funnel 漏斗&lt;/td>
 &lt;td>多步驟轉換率（session 級 JOIN）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cohort 留存表&lt;/td>
 &lt;td>時間窗口 × 群組矩陣&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RFM 分群散佈&lt;/td>
 &lt;td>三維度分位數計算&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Error 趨勢圖（長期）&lt;/td>
 &lt;td>30 天 × 每小時的時間序列&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>效能 P95 趨勢&lt;/td>
 &lt;td>percentile_cont 視窗函數&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="對應的事件消費-1">對應的事件消費&lt;/h3>
&lt;p>PostgreSQL 層消費的事件和 SQLite 相同（Ingestion 不變），但消費方式從「單筆/單 session」擴展到「跨 session/跨使用者」：&lt;/p></description><content:encoded><![CDATA[<p>Collector 的可插拔 Storage Backend 分成兩個功能層級。分界線是查詢模式 — SQLite 能高效處理的查詢定義了簡單版的功能邊界，超出的查詢需求觸發 PostgreSQL 的引入。所有事件都經過同一個 Ingestion domain，差異在 Query 和 Dashboard domain 能提供什麼能力。</p>
<h2 id="sqlite-層開發者工具">SQLite 層：開發者工具</h2>
<p>SQLite 層提供的功能聚焦在「開發者自己 debug 和監控」。所有查詢都是單一維度的 — 按時間、按類型、按名稱過濾，不需要跨事件 JOIN 或跨使用者聚合。</p>
<h3 id="承載的功能">承載的功能</h3>
<table>
  <thead>
      <tr>
          <th>功能</th>
          <th>查詢模式</th>
          <th>SQL 範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>最近 error 列表</td>
          <td>按 type + 時間過濾</td>
          <td><code>WHERE type='error' ORDER BY ts DESC LIMIT 20</code></td>
      </tr>
      <tr>
          <td>Error 計數（按 name 分群）</td>
          <td>單表 GROUP BY</td>
          <td><code>SELECT name, COUNT(*) FROM events WHERE type='error' GROUP BY name</code></td>
      </tr>
      <tr>
          <td>單次 session 回放</td>
          <td>按 session_id 過濾</td>
          <td><code>WHERE session_id='xxx' ORDER BY ts</code></td>
      </tr>
      <tr>
          <td>事件時間軸</td>
          <td>按時間排序</td>
          <td><code>WHERE ts BETWEEN ? AND ? ORDER BY ts</code></td>
      </tr>
      <tr>
          <td>基本 rule engine</td>
          <td>逐筆事件評估</td>
          <td>收到事件時逐條比對 rule（不需要查歷史）</td>
      </tr>
      <tr>
          <td>CLI 查詢</td>
          <td>任意過濾</td>
          <td><code>WHERE type=? AND name LIKE ? AND ts &gt; ?</code></td>
      </tr>
  </tbody>
</table>
<p>這些功能覆蓋開發者日常 debug 和監控的核心操作 — 查錯誤、看時間軸、回放 session、設規則告警。</p>
<h3 id="對應的-dashboard-視圖">對應的 Dashboard 視圖</h3>
<table>
  <thead>
      <tr>
          <th>視圖</th>
          <th>顯示</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>總覽頁</td>
          <td>最近 1 小時的事件計數（按 type 分）+ 最近 error 列表</td>
      </tr>
      <tr>
          <td>事件詳情</td>
          <td>單筆事件的完整 JSON</td>
      </tr>
      <tr>
          <td>Session 回放</td>
          <td>單次 session 內的事件序列</td>
      </tr>
  </tbody>
</table>
<h3 id="對應的事件消費">對應的事件消費</h3>
<p>SQLite 層消費所有四類事件，但消費方式是「單筆或單 session 級查詢」：</p>
<table>
  <thead>
      <tr>
          <th>事件類型</th>
          <th>消費方式</th>
          <th>保留需求</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>event</td>
          <td>按名稱計數、按 session 排列</td>
          <td>原始 7 天（debug）</td>
      </tr>
      <tr>
          <td>error</td>
          <td>按名稱分群、按時間排列、看 stack trace</td>
          <td>原始 30 天（error 追蹤價值較長）</td>
      </tr>
      <tr>
          <td>metric</td>
          <td>按名稱查最近 N 筆的值</td>
          <td>原始 7 天 + 每小時聚合 90 天</td>
      </tr>
      <tr>
          <td>lifecycle</td>
          <td>按 session 排列、看狀態轉換</td>
          <td>原始 7 天</td>
      </tr>
  </tbody>
</table>
<h2 id="postgresql-層行為分析">PostgreSQL 層：行為分析</h2>
<p>PostgreSQL 層在 SQLite 層的基礎上加入「跨 session、跨使用者的聚合分析」。這些查詢需要 JOIN 多張表、計算時間窗口、處理大量資料的 GROUP BY — SQLite 的單寫者模型和有限的查詢最佳化器在這些場景下效能不足。</p>
<h3 id="觸發引入-postgresql-的功能需求">觸發引入 PostgreSQL 的功能需求</h3>
<table>
  <thead>
      <tr>
          <th>功能需求</th>
          <th>為什麼 SQLite 不夠</th>
          <th>PostgreSQL 提供什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Funnel 分析</strong></td>
          <td>跨大量 session 的 multi-step JOIN 和聚合效能不足</td>
          <td>Window functions + 高效 JOIN</td>
      </tr>
      <tr>
          <td><strong>Cohort 留存</strong></td>
          <td>需要按「註冊週」分群、計算每週的回訪率</td>
          <td>Date functions + 大規模 GROUP BY</td>
      </tr>
      <tr>
          <td><strong>RFM 分群</strong></td>
          <td>需要跨所有使用者計算 recency/frequency/monetary</td>
          <td>全表聚合 + 分位數計算</td>
      </tr>
      <tr>
          <td><strong>時間趨勢 dashboard</strong></td>
          <td>需要「過去 30 天每小時的 error P95」</td>
          <td>時間分桶 + percentile 函數</td>
      </tr>
      <tr>
          <td><strong>高併發寫入</strong></td>
          <td>多個 SDK 同時 flush 且持續出現 database is locked</td>
          <td>連線池 + 並行寫入</td>
      </tr>
      <tr>
          <td><strong>長期保留 + 聚合</strong></td>
          <td>降採樣的 materialized view</td>
          <td>REFRESH MATERIALIZED VIEW</td>
      </tr>
  </tbody>
</table>
<h3 id="判斷公式">判斷公式</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">需要 funnel / cohort / RFM 任一 → PostgreSQL
</span></span><span class="line"><span class="ln">2</span><span class="cl">需要跨使用者聚合（不只看自己的資料） → PostgreSQL
</span></span><span class="line"><span class="ln">3</span><span class="cl">需要高併發寫入（多個 SDK 同時 flush 且持續出現 database is locked 錯誤） → PostgreSQL
</span></span><span class="line"><span class="ln">4</span><span class="cl">以上都不需要 → SQLite 足夠</span></span></code></pre></div><h3 id="對應的-dashboard-視圖sqlite-層不提供">對應的 Dashboard 視圖（SQLite 層不提供）</h3>
<table>
  <thead>
      <tr>
          <th>視圖</th>
          <th>查詢模式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Funnel 漏斗</td>
          <td>多步驟轉換率（session 級 JOIN）</td>
      </tr>
      <tr>
          <td>Cohort 留存表</td>
          <td>時間窗口 × 群組矩陣</td>
      </tr>
      <tr>
          <td>RFM 分群散佈</td>
          <td>三維度分位數計算</td>
      </tr>
      <tr>
          <td>Error 趨勢圖（長期）</td>
          <td>30 天 × 每小時的時間序列</td>
      </tr>
      <tr>
          <td>效能 P95 趨勢</td>
          <td>percentile_cont 視窗函數</td>
      </tr>
  </tbody>
</table>
<h3 id="對應的事件消費-1">對應的事件消費</h3>
<p>PostgreSQL 層消費的事件和 SQLite 相同（Ingestion 不變），但消費方式從「單筆/單 session」擴展到「跨 session/跨使用者」：</p>
<table>
  <thead>
      <tr>
          <th>事件類型</th>
          <th>SQLite 層消費</th>
          <th>PostgreSQL 層新增消費</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>event</td>
          <td>按名稱計數</td>
          <td>funnel 步驟轉換、cohort 行為分群</td>
      </tr>
      <tr>
          <td>error</td>
          <td>按名稱分群</td>
          <td>跨版本 error 率比較、P95 回應時間趨勢</td>
      </tr>
      <tr>
          <td>metric</td>
          <td>最近 N 筆值</td>
          <td>長期趨勢（materialized view 預聚合）</td>
      </tr>
      <tr>
          <td>lifecycle</td>
          <td>單 session 排列</td>
          <td>session 長度分佈、留存率計算</td>
      </tr>
  </tbody>
</table>
<h2 id="domain-的分層影響">Domain 的分層影響</h2>
<table>
  <thead>
      <tr>
          <th>Domain</th>
          <th>SQLite 層</th>
          <th>PostgreSQL 層新增</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Ingestion</strong></td>
          <td>HTTP POST → 驗證 → 寫入</td>
          <td>不變（寫入目標換 backend）</td>
      </tr>
      <tr>
          <td><strong>Storage</strong></td>
          <td>SQLite embedded</td>
          <td>PostgreSQL + 連線池</td>
      </tr>
      <tr>
          <td><strong>Query</strong></td>
          <td>單表過濾 + 單表 GROUP BY</td>
          <td>JOIN + window function + percentile</td>
      </tr>
      <tr>
          <td><strong>Rule</strong></td>
          <td>逐筆事件即時評估</td>
          <td>不變（rule 不依賴聚合查詢）</td>
      </tr>
      <tr>
          <td><strong>Dashboard</strong></td>
          <td>總覽 + 事件詳情 + session 回放</td>
          <td>新增 funnel / cohort / RFM / 趨勢圖</td>
      </tr>
  </tbody>
</table>
<p>Ingestion 和 Rule 兩個 domain 和 storage backend 無關 — 事件進來的方式和規則評估的邏輯不因 backend 改變。Query 和 Dashboard 是分層影響最大的兩個 domain — PostgreSQL 層的查詢能力決定了 Dashboard 能提供什麼視圖。</p>
<h2 id="實作邊界">實作邊界</h2>
<p>Storage interface 用 Go 的 interface composition 分成兩層：</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">type</span> <span class="nx">BasicStorage</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nf">Store</span><span class="p">(</span><span class="nx">event</span> <span class="nx">Event</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nf">Query</span><span class="p">(</span><span class="nx">filter</span> <span class="nx">QueryFilter</span><span class="p">)</span> <span class="p">([]</span><span class="nx">Event</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nf">Close</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nf">Downsample</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nf">Purge</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">AnalyticsStorage</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">BasicStorage</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nf">Aggregate</span><span class="p">(</span><span class="nx">spec</span> <span class="nx">AggregateSpec</span><span class="p">)</span> <span class="p">(</span><span class="nx">AggregateResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nf">Funnel</span><span class="p">(</span><span class="nx">steps</span> <span class="p">[]</span><span class="kt">string</span><span class="p">,</span> <span class="nx">timeWindow</span> <span class="nx">Duration</span><span class="p">)</span> <span class="p">(</span><span class="nx">FunnelResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nf">Cohort</span><span class="p">(</span><span class="nx">groupBy</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">metric</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">CohortResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>SQLite implementation 只實作 <code>BasicStorage</code>。PostgreSQL implementation 實作 <code>AnalyticsStorage</code>。Dashboard 用 Go 的 type assertion（<code>if as, ok := storage.(AnalyticsStorage); ok { ... }</code>）判斷能力 — funnel/cohort 視圖在 SQLite 模式下不顯示入口，而非顯示後報錯。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>可插拔 Storage Backend 的架構 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a></li>
<li>事件枚舉方法（哪些事件要收） → <a href="/blog/monitoring/01-mental-model/event-enumeration-method/" data-link-title="事件枚舉與補齊檢查" data-link-desc="從操作盤點系統性地推導出完整的事件清單 — 四類補齊檢查確保沒有遺漏、粒度判準確保每個事件只記一個事實">事件枚舉與補齊檢查</a></li>
<li>分層保留策略 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進的分層保留段</a></li>
<li>Funnel 分析的完整方法論 → <a href="/blog/monitoring/08-business-analytics/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="使用者在哪一步流失 — 從事件序列計算每步轉換率、找出流失最嚴重的步驟、區分設計問題和技術問題">Funnel analysis</a></li>
<li>查詢消費模式（各場景需要什麼事件）→ <a href="/blog/monitoring/04-collector/query-consumption-patterns/" data-link-title="查詢消費模式" data-link-desc="Debug / Alerting / 產品決策 / 安全審計 / 效能監控 — 五種查詢場景各需要什麼事件、什麼欄位、什麼查詢模式">查詢消費模式</a></li>
</ul>
]]></content:encoded></item><item><title>前端感測器設計</title><link>https://tarrragon.github.io/blog/monitoring/03-sdk-design/frontend-sensor-design/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/03-sdk-design/frontend-sensor-design/</guid><description>&lt;p>感測器是 SDK 主動偵測使用者行為的元件。和 &lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/auto-intercept/" data-link-title="自動攔截機制" data-link-desc="JS window.onerror / Flutter FlutterError.onError / Python sys.excepthook — 各平台攔截未捕獲例外的機制和限制">自動攔截機制&lt;/a> 的被動攔截不同 — auto-intercept 攔截的是系統級事件（uncaught exception、unhandled rejection），感測器偵測的是業務級行為（使用者點了什麼、看了哪個畫面、操作花了多久）。兩者互補：auto-intercept 提供 error 和 lifecycle 的基礎層，感測器提供 event 和 metric 的業務層。&lt;/p>
&lt;h2 id="點擊觸碰感測器">點擊/觸碰感測器&lt;/h2>
&lt;p>點擊感測器偵測使用者和 UI 元素的互動 — 按鈕點擊、連結觸碰、選單選擇。每次互動產生一個 event 類型的事件。&lt;/p>
&lt;h3 id="哪些元素值得追蹤">哪些元素值得追蹤&lt;/h3>
&lt;p>追蹤粒度的判斷依據是「這個互動是否對應一個有意義的使用者意圖」。&lt;/p>
&lt;p>有意義的互動（值得追蹤）：提交表單、點擊導航按鈕、觸發功能操作（連線、配對、匯出）。這些互動對應使用者的明確意圖，是 &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="使用者在哪一步流失 — 從事件序列計算每步轉換率、找出流失最嚴重的步驟、區分設計問題和技術問題">funnel 分析&lt;/a> 的步驟候選。&lt;/p>
&lt;p>低價值的互動（通常不追蹤）：滾動、hover、重複的相同操作（每秒多次的按鈕連按）。這些互動要麼太頻繁（滾動每秒觸發數十次），要麼不代表新的使用者意圖。&lt;/p>
&lt;h3 id="實作方式">實作方式&lt;/h3>
&lt;p>&lt;strong>Web（JS/TS）&lt;/strong>：在 document 層級用 event delegation 攔截 click 事件，過濾出帶 &lt;code>data-track&lt;/code> attribute 的元素。開發者在需要追蹤的元素上加 &lt;code>data-track=&amp;quot;connect-button&amp;quot;&lt;/code>，感測器自動收集。不追蹤所有 click — 只追蹤被標記的。&lt;/p>
&lt;p>&lt;strong>Flutter&lt;/strong>：用 NavigatorObserver 或 custom GestureDetector wrapper。GestureDetector 包裝在需要追蹤的 widget 外層，onTap 觸發時送出事件。&lt;/p>
&lt;h3 id="效能影響">效能影響&lt;/h3>
&lt;p>Event delegation 在 document 層級只有一個 listener，效能影響接近零。瓶頸在事件產生頻率 — 如果追蹤了高頻操作（每秒多次的滑動），事件進入 buffer 的速度可能超過 flush 的速度。用取樣控制（見本章末段）。&lt;/p>
&lt;h2 id="導航路由感測器">導航/路由感測器&lt;/h2>
&lt;p>導航感測器偵測使用者在不同畫面之間的切換 — page view、screen view、route change。每次切換產生一個 lifecycle 類型的事件。&lt;/p>
&lt;h3 id="平台差異">平台差異&lt;/h3>
&lt;p>&lt;strong>Web SPA&lt;/strong>：SPA 的 route 變換不觸發頁面載入，需要主動偵測 URL 變化。兩種偵測方式：&lt;/p>
&lt;ul>
&lt;li>History API 攔截：覆寫 &lt;code>pushState&lt;/code> / &lt;code>replaceState&lt;/code>，攔截 &lt;code>popstate&lt;/code> 事件&lt;/li>
&lt;li>框架層級 Hook：React Router 的 &lt;code>useLocation&lt;/code>、Vue Router 的 &lt;code>afterEach&lt;/code> guard&lt;/li>
&lt;/ul>
&lt;p>History API 攔截是 SDK 層的通用做法（不依賴框架）；框架 Hook 更精確但需要使用者整合（見 &lt;a href="https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/js-ts-platform/" data-link-title="JS/TS 平台適配" data-link-desc="CORS 限制、Service Worker 攔截、SPA 路由變換偵測 — 瀏覽器環境中 SDK 需要處理的平台特殊問題">JS/TS 平台&lt;/a> 的 SPA 路由段）。&lt;/p>
&lt;p>&lt;strong>Flutter&lt;/strong>：用 &lt;code>NavigatorObserver&lt;/code> 的 &lt;code>didPush&lt;/code> / &lt;code>didPop&lt;/code> / &lt;code>didReplace&lt;/code> 回呼。每次路由變化自動觸發，不需要使用者在每個頁面手動埋點。&lt;/p>
&lt;p>&lt;strong>Python CLI/Hook&lt;/strong>：沒有「畫面切換」的概念。對應的 lifecycle 事件是 &lt;code>hook.start&lt;/code> / &lt;code>hook.complete&lt;/code> — 每個 Hook 執行視為一個「畫面」。&lt;/p>
&lt;h3 id="事件-schema">事件 schema&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;lifecycle&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;screen.view&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;data&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;screen_name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;TerminalScreen&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;previous_screen&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;HomeScreen&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;navigation_method&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;push&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>navigation_method&lt;/code>（push / pop / replace / go）記錄導航方式，和 &lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/go-push-semantics/" data-link-title="go vs push vs pushReplacement 的 UX 語意表" data-link-desc="三種導航方法對堆疊、back 行為、使用者心理模型的影響 — 選擇依據是使用者的意圖而非技術方便">go vs push 的 UX 語意&lt;/a> 對應。&lt;/p>
&lt;h2 id="錯誤邊界感測器">錯誤邊界感測器&lt;/h2>
&lt;p>錯誤邊界感測器攔截元件級的 error — 和 auto-intercept 的全域 error 攔截互補。&lt;/p></description><content:encoded><![CDATA[<p>感測器是 SDK 主動偵測使用者行為的元件。和 <a href="/blog/monitoring/03-sdk-design/auto-intercept/" data-link-title="自動攔截機制" data-link-desc="JS window.onerror / Flutter FlutterError.onError / Python sys.excepthook — 各平台攔截未捕獲例外的機制和限制">自動攔截機制</a> 的被動攔截不同 — auto-intercept 攔截的是系統級事件（uncaught exception、unhandled rejection），感測器偵測的是業務級行為（使用者點了什麼、看了哪個畫面、操作花了多久）。兩者互補：auto-intercept 提供 error 和 lifecycle 的基礎層，感測器提供 event 和 metric 的業務層。</p>
<h2 id="點擊觸碰感測器">點擊/觸碰感測器</h2>
<p>點擊感測器偵測使用者和 UI 元素的互動 — 按鈕點擊、連結觸碰、選單選擇。每次互動產生一個 event 類型的事件。</p>
<h3 id="哪些元素值得追蹤">哪些元素值得追蹤</h3>
<p>追蹤粒度的判斷依據是「這個互動是否對應一個有意義的使用者意圖」。</p>
<p>有意義的互動（值得追蹤）：提交表單、點擊導航按鈕、觸發功能操作（連線、配對、匯出）。這些互動對應使用者的明確意圖，是 <a href="/blog/monitoring/08-business-analytics/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="使用者在哪一步流失 — 從事件序列計算每步轉換率、找出流失最嚴重的步驟、區分設計問題和技術問題">funnel 分析</a> 的步驟候選。</p>
<p>低價值的互動（通常不追蹤）：滾動、hover、重複的相同操作（每秒多次的按鈕連按）。這些互動要麼太頻繁（滾動每秒觸發數十次），要麼不代表新的使用者意圖。</p>
<h3 id="實作方式">實作方式</h3>
<p><strong>Web（JS/TS）</strong>：在 document 層級用 event delegation 攔截 click 事件，過濾出帶 <code>data-track</code> attribute 的元素。開發者在需要追蹤的元素上加 <code>data-track=&quot;connect-button&quot;</code>，感測器自動收集。不追蹤所有 click — 只追蹤被標記的。</p>
<p><strong>Flutter</strong>：用 NavigatorObserver 或 custom GestureDetector wrapper。GestureDetector 包裝在需要追蹤的 widget 外層，onTap 觸發時送出事件。</p>
<h3 id="效能影響">效能影響</h3>
<p>Event delegation 在 document 層級只有一個 listener，效能影響接近零。瓶頸在事件產生頻率 — 如果追蹤了高頻操作（每秒多次的滑動），事件進入 buffer 的速度可能超過 flush 的速度。用取樣控制（見本章末段）。</p>
<h2 id="導航路由感測器">導航/路由感測器</h2>
<p>導航感測器偵測使用者在不同畫面之間的切換 — page view、screen view、route change。每次切換產生一個 lifecycle 類型的事件。</p>
<h3 id="平台差異">平台差異</h3>
<p><strong>Web SPA</strong>：SPA 的 route 變換不觸發頁面載入，需要主動偵測 URL 變化。兩種偵測方式：</p>
<ul>
<li>History API 攔截：覆寫 <code>pushState</code> / <code>replaceState</code>，攔截 <code>popstate</code> 事件</li>
<li>框架層級 Hook：React Router 的 <code>useLocation</code>、Vue Router 的 <code>afterEach</code> guard</li>
</ul>
<p>History API 攔截是 SDK 層的通用做法（不依賴框架）；框架 Hook 更精確但需要使用者整合（見 <a href="/blog/monitoring/05-platform-adaptation/js-ts-platform/" data-link-title="JS/TS 平台適配" data-link-desc="CORS 限制、Service Worker 攔截、SPA 路由變換偵測 — 瀏覽器環境中 SDK 需要處理的平台特殊問題">JS/TS 平台</a> 的 SPA 路由段）。</p>
<p><strong>Flutter</strong>：用 <code>NavigatorObserver</code> 的 <code>didPush</code> / <code>didPop</code> / <code>didReplace</code> 回呼。每次路由變化自動觸發，不需要使用者在每個頁面手動埋點。</p>
<p><strong>Python CLI/Hook</strong>：沒有「畫面切換」的概念。對應的 lifecycle 事件是 <code>hook.start</code> / <code>hook.complete</code> — 每個 Hook 執行視為一個「畫面」。</p>
<h3 id="事件-schema">事件 schema</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;lifecycle&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;screen.view&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;data&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nt">&#34;screen_name&#34;</span><span class="p">:</span> <span class="s2">&#34;TerminalScreen&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nt">&#34;previous_screen&#34;</span><span class="p">:</span> <span class="s2">&#34;HomeScreen&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nt">&#34;navigation_method&#34;</span><span class="p">:</span> <span class="s2">&#34;push&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>navigation_method</code>（push / pop / replace / go）記錄導航方式，和 <a href="/blog/ux-design/05-navigation-patterns/go-push-semantics/" data-link-title="go vs push vs pushReplacement 的 UX 語意表" data-link-desc="三種導航方法對堆疊、back 行為、使用者心理模型的影響 — 選擇依據是使用者的意圖而非技術方便">go vs push 的 UX 語意</a> 對應。</p>
<h2 id="錯誤邊界感測器">錯誤邊界感測器</h2>
<p>錯誤邊界感測器攔截元件級的 error — 和 auto-intercept 的全域 error 攔截互補。</p>
<h3 id="和-auto-intercept-的職責分工">和 auto-intercept 的職責分工</h3>
<table>
  <thead>
      <tr>
          <th>層級</th>
          <th>機制</th>
          <th>攔截什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>全域</td>
          <td>auto-intercept（<code>window.onerror</code> / <code>FlutterError.onError</code>）</td>
          <td>uncaught exception、未處理的 Promise rejection</td>
      </tr>
      <tr>
          <td>元件</td>
          <td>錯誤邊界感測器（React ErrorBoundary / Flutter Widget error handler）</td>
          <td>元件渲染失敗、子樹 error</td>
      </tr>
  </tbody>
</table>
<p>全域攔截捕獲「逃逸到頂層的 error」，錯誤邊界捕獲「在元件層級就被攔住的 error」。如果一個 error 被元件的 ErrorBoundary 捕獲，它不會觸發 <code>window.onerror</code> — auto-intercept 看不到它。錯誤邊界感測器填補這個缺口。</p>
<h3 id="實作方式-1">實作方式</h3>
<p><strong>React</strong>：ErrorBoundary 元件的 <code>componentDidCatch</code> 回呼中呼叫 <code>monitor.error()</code>。</p>
<p><strong>Flutter</strong>：在 Widget 層用 <code>ErrorWidget.builder</code> 或自訂的 error handling widget。</p>
<h3 id="額外-context">額外 context</h3>
<p>錯誤邊界感測器比全域攔截多一個 context — 知道 error 發生在哪個元件（component name / widget name）。這個資訊在 error 的 data schema 中記錄為 <code>component</code> 欄位。</p>
<h2 id="效能標記感測器">效能標記感測器</h2>
<p>效能標記感測器量測操作的延遲和系統的渲染表現。產生 metric 類型的事件。</p>
<h3 id="web-core-vitals">Web Core Vitals</h3>
<p>Web 平台用 <code>PerformanceObserver</code> API 自動收集三個核心指標：</p>
<ul>
<li><strong>LCP</strong>（Largest Contentful Paint）：最大內容元素的載入時間</li>
<li><strong>FID</strong>（First Input Delay）：首次互動的延遲</li>
<li><strong>CLS</strong>（Cumulative Layout Shift）：累計佈局位移分數</li>
</ul>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">new</span> <span class="nx">PerformanceObserver</span><span class="p">((</span><span class="nx">list</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">for</span> <span class="p">(</span><span class="kr">const</span> <span class="nx">entry</span> <span class="k">of</span> <span class="nx">list</span><span class="p">.</span><span class="nx">getEntries</span><span class="p">())</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">monitor</span><span class="p">.</span><span class="nx">metric</span><span class="p">(</span><span class="sb">`web.vitals.</span><span class="si">${</span><span class="nx">entry</span><span class="p">.</span><span class="nx">entryType</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">      <span class="nx">value</span><span class="o">:</span> <span class="nx">entry</span><span class="p">.</span><span class="nx">startTime</span> <span class="o">||</span> <span class="nx">entry</span><span class="p">.</span><span class="nx">value</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">      <span class="nx">url</span><span class="o">:</span> <span class="nx">location</span><span class="p">.</span><span class="nx">pathname</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}).</span><span class="nx">observe</span><span class="p">({</span> <span class="nx">type</span><span class="o">:</span> <span class="s1">&#39;largest-contentful-paint&#39;</span><span class="p">,</span> <span class="nx">buffered</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span></span></span></code></pre></div><p>實務上依 entryType 分別取值（LCP 用 <code>startTime</code>、CLS 用 <code>value</code>、FID 用 <code>processingStart - startTime</code>），上述範例簡化示意。</p>
<h3 id="flutter-frame-timing">Flutter frame timing</h3>
<p>Flutter 用 <code>SchedulerBinding.addTimingsCallback</code> 偵測掉幀：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">SchedulerBinding</span><span class="p">.</span><span class="n">instance</span><span class="p">.</span><span class="n">addTimingsCallback</span><span class="p">((</span><span class="n">timings</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="k">for</span> <span class="p">(</span><span class="kd">final</span> <span class="n">t</span> <span class="k">in</span> <span class="n">timings</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">t</span><span class="p">.</span><span class="n">totalSpan</span> <span class="o">&gt;</span> <span class="kd">const</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">milliseconds:</span> <span class="m">16</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">      <span class="n">monitor</span><span class="p">.</span><span class="n">metric</span><span class="p">(</span><span class="s1">&#39;render.frame_drop&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="s1">&#39;build_ms&#39;</span><span class="o">:</span> <span class="n">t</span><span class="p">.</span><span class="n">buildDuration</span><span class="p">.</span><span class="n">inMilliseconds</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="s1">&#39;raster_ms&#39;</span><span class="o">:</span> <span class="n">t</span><span class="p">.</span><span class="n">rasterDuration</span><span class="p">.</span><span class="n">inMilliseconds</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>16ms 是 60fps 的單幀預算。超過代表掉幀。</p>
<h3 id="自訂-duration-量測">自訂 duration 量測</h3>
<p>業務操作的延遲用手動標記量測：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">final</span> <span class="n">stopwatch</span> <span class="o">=</span> <span class="n">Stopwatch</span><span class="p">()..</span><span class="n">start</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">await</span> <span class="n">connectToTerminal</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">stopwatch</span><span class="p">.</span><span class="n">stop</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">monitor</span><span class="p">.</span><span class="n">metric</span><span class="p">(</span><span class="s1">&#39;terminal.connect.duration&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="s1">&#39;duration_ms&#39;</span><span class="o">:</span> <span class="n">stopwatch</span><span class="p">.</span><span class="n">elapsedMilliseconds</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><h2 id="輸入敏感度感測器">輸入敏感度感測器</h2>
<p>輸入敏感度感測器偵測使用者正在輸入敏感資料 — 密碼欄位、API key 輸入、信用卡號碼。這個感測器的責任是<strong>觸發 redaction，而非記錄輸入內容</strong>。</p>
<h3 id="偵測邏輯">偵測邏輯</h3>
<p><strong>Web</strong>：偵測 <code>&lt;input type=&quot;password&quot;&gt;</code>、帶有 <code>autocomplete=&quot;cc-number&quot;</code> 或 <code>data-sensitive</code> attribute 的欄位。當使用者 focus 這些欄位時，標記當前 session 進入「敏感輸入模式」— 後續的事件自動加嚴 <a href="/blog/monitoring/knowledge-cards/redaction/" data-link-title="Redaction" data-link-desc="說明在事件資料離開 client 之前把敏感欄位的值替換成遮罩或移除的機制">redaction</a> 規則（例如暫停記錄按鍵事件）。</p>
<p><strong>Flutter</strong>：偵測 <code>TextField</code> 的 <code>obscureText: true</code> 或 <code>enableIMEPersonalizedLearning: false</code>（見 <a href="/blog/ux-design/03-input-mechanism/ime-security-checklist/" data-link-title="安全敏感輸入框的 IME 控制 checklist" data-link-desc="處理密碼、API key、伺服器路徑等 secret 的輸入框需要關閉 IME 的個人化學習和自動校正 — 安全要求而非 UX 偏好">安全敏感輸入框的 IME 控制</a>）。</p>
<h3 id="不記錄的原則">不記錄的原則</h3>
<p>輸入敏感度感測器偵測「使用者正在輸入敏感內容」這個事實，但不記錄輸入的內容本身。送出的事件只包含：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;lifecycle&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;input.sensitive_mode.entered&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;data&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;field_type&#34;</span><span class="p">:</span> <span class="s2">&#34;password&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h2 id="取樣策略設計">取樣策略設計</h2>
<p>感測器產生的事件量可能很大（效能標記每 30 秒一筆 × 活躍使用者數）。取樣控制事件量、避免 SDK 和 collector 的資源壓力。</p>
<h3 id="三種取樣模式">三種取樣模式</h3>
<p><strong>全收</strong>：每筆事件都送出。適合事件量低且每筆都有價值的類型 — error（每筆都可能是新 bug）、lifecycle 狀態轉換（量低）、認證失敗（安全敏感）。</p>
<p><strong>百分比取樣</strong>：隨機丟棄一定比例的事件。適合高頻的效能和行為事件。取樣率由 SDK config 控制：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">sensors</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="nt">metric</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="nt">render.frame_drop</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">sampling</span><span class="p">:</span><span class="w"> </span><span class="m">0.1</span><span class="w"> </span>}<span class="w">    </span><span class="c"># 只收 10%</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">    </span><span class="nt">resource.memory</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">sampling</span><span class="p">:</span><span class="w"> </span><span class="m">0.5</span><span class="w"> </span>}<span class="w">       </span><span class="c"># 收 50%</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">  </span><span class="nt">event</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="nt">feature.*.used</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">sampling</span><span class="p">:</span><span class="w"> </span><span class="m">1.0</span><span class="w"> </span>}<span class="w">        </span><span class="c"># 全收</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w">    </span><span class="nt">click.*</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">sampling</span><span class="p">:</span><span class="w"> </span><span class="m">0.1</span><span class="w"> </span>}<span class="w">               </span><span class="c"># 只收 10%</span></span></span></code></pre></div><p>百分比取樣的代價是低機率事件可能被漏掉（取樣 10% 時、發生 5 次的事件可能一次都沒收到）。</p>
<p><strong>條件取樣</strong>：正常情況下取樣、特定條件下全收。適合「平時不需要全量但問題發生時需要完整資料」的場景。例：正常 session 取樣 10%、但 session 內發生 error 後、該 session 剩餘事件全收（error session 的完整 context 比正常 session 更有價值）。</p>
<h3 id="取樣率的管理">取樣率的管理</h3>
<p>取樣率可以從三個層級設定：</p>
<table>
  <thead>
      <tr>
          <th>層級</th>
          <th>設定方式</th>
          <th>適用場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SDK 本地 config</td>
          <td>隨 app 版本部署</td>
          <td>固定的基線取樣率</td>
      </tr>
      <tr>
          <td>Collector 下發</td>
          <td>SDK 啟動時從 collector 取得 config</td>
          <td>動態調整、不需要重新部署 app</td>
      </tr>
      <tr>
          <td>Feature flag 服務</td>
          <td>整合 LaunchDarkly / Unleash</td>
          <td>實驗期間對特定群組調整取樣</td>
      </tr>
  </tbody>
</table>
<p>三個層級由上到下優先順序遞增 — feature flag 覆蓋 collector config、collector config 覆蓋本地 config。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>動機驅動的事件設計（哪些動機需要哪些感測器） → <a href="/blog/monitoring/01-mental-model/motivation-to-event-mapping/" data-link-title="動機驅動的事件設計" data-link-desc="Debug / 商業 / 資安 / 效能四個動機各自需要什麼事件 — 從「為什麼收」反推「收什麼」和「什麼階段啟用」">動機驅動的事件設計</a></li>
<li>感測器的啟停控制和生命週期 → <a href="/blog/monitoring/03-sdk-design/sensor-lifecycle-management/" data-link-title="感測器生命週期管理" data-link-desc="產品生命週期的五個階段各啟用什麼感測器 — feature flag 整合、取樣率動態調整、感測器開關的可觀察性">感測器生命週期管理</a></li>
<li>被動攔截機制（和感測器互補） → <a href="/blog/monitoring/03-sdk-design/auto-intercept/" data-link-title="自動攔截機制" data-link-desc="JS window.onerror / Flutter FlutterError.onError / Python sys.excepthook — 各平台攔截未捕獲例外的機制和限制">自動攔截機制</a></li>
<li>安全敏感輸入的完整 checklist → <a href="/blog/ux-design/03-input-mechanism/ime-security-checklist/" data-link-title="安全敏感輸入框的 IME 控制 checklist" data-link-desc="處理密碼、API key、伺服器路徑等 secret 的輸入框需要關閉 IME 的個人化學習和自動校正 — 安全要求而非 UX 偏好">安全敏感輸入框的 IME 控制</a></li>
</ul>
]]></content:encoded></item><item><title>動機驅動的事件設計</title><link>https://tarrragon.github.io/blog/monitoring/01-mental-model/motivation-to-event-mapping/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/01-mental-model/motivation-to-event-mapping/</guid><description>&lt;p>事件設計是三維結構：動機（為什麼收）決定需要什麼事件、感測器（怎麼收）決定在前端哪裡埋點、生命週期（什麼時候收）決定各事件在哪個產品階段啟用。本章展開&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>的四個方向到具體事件名稱級。從動機出發反推事件清單，比從技術能力出發（「SDK 能收什麼就收什麼」）更精準 — 每個事件都能回指一個具體的消費場景。&lt;/p>
&lt;h2 id="debug-動機">Debug 動機&lt;/h2>
&lt;p>Debug 動機驅動的事件收集目標是「問題發生時、開發者能從事件記錄中重建 context 並定位根因」。&lt;/p>
&lt;h3 id="要偵測的行為">要偵測的行為&lt;/h3>
&lt;ul>
&lt;li>多步驟流程的每一步完成或失敗（連線 → 認證 → 資料交換）&lt;/li>
&lt;li>系統狀態轉換（前景/背景、連線/斷線、登入/登出）&lt;/li>
&lt;li>非預期例外（uncaught exception、network error、timeout）&lt;/li>
&lt;li>使用者最近的操作序列（問題發生前做了什麼）&lt;/li>
&lt;/ul>
&lt;h3 id="事件表">事件表&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>事件名稱&lt;/th>
 &lt;th>類型&lt;/th>
 &lt;th>觸發時機&lt;/th>
 &lt;th>data schema 重點&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>{feature}.step.done&lt;/td>
 &lt;td>lifecycle&lt;/td>
 &lt;td>流程步驟完成&lt;/td>
 &lt;td>step_name, duration_ms&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>{feature}.step.failed&lt;/td>
 &lt;td>error&lt;/td>
 &lt;td>流程步驟失敗&lt;/td>
 &lt;td>step_name, error, context&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>app.exception&lt;/td>
 &lt;td>error&lt;/td>
 &lt;td>uncaught exception&lt;/td>
 &lt;td>message, stack_trace, component&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ws.connected / ws.disconnected&lt;/td>
 &lt;td>lifecycle&lt;/td>
 &lt;td>連線狀態變化&lt;/td>
 &lt;td>url, reason, code&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>app.foreground / app.background&lt;/td>
 &lt;td>lifecycle&lt;/td>
 &lt;td>app 前後景切換&lt;/td>
 &lt;td>duration_in_background&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>{action}.completed&lt;/td>
 &lt;td>event&lt;/td>
 &lt;td>使用者完成操作&lt;/td>
 &lt;td>action_detail&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="查詢場景">查詢場景&lt;/h3>
&lt;p>&lt;strong>Session 回放&lt;/strong>：按 session_id 過濾、按時間排序，還原「使用者做了什麼 → 系統發生了什麼 → 問題在哪裡出現」。&lt;/p>
&lt;p>&lt;strong>Error 根因定位&lt;/strong>：按 error name GROUP BY，找出最常出現的錯誤。單筆 error 的 stack_trace + 同 session 的 lifecycle 事件組合，判斷失敗發生在流程的哪一步。&lt;/p>
&lt;p>&lt;strong>最近 N 個操作&lt;/strong>：error 發生前的 10-20 個 event/lifecycle 事件，等同 Sentry 的 breadcrumb trail。&lt;/p>
&lt;h3 id="生命週期階段">生命週期階段&lt;/h3>
&lt;p>開發期起全開。Debug 事件是最早需要的 — 實機測試階段就依賴這些事件定位問題。error 類和 lifecycle 類不做取樣（量低且每筆都可能是線索）。&lt;/p>
&lt;h2 id="商業動機">商業動機&lt;/h2>
&lt;p>商業動機驅動的事件收集目標是「回答產品決策的問題 — 使用者在哪裡流失、不同群組行為有什麼差異、哪些功能被使用」。&lt;/p>
&lt;h3 id="要偵測的行為-1">要偵測的行為&lt;/h3>
&lt;ul>
&lt;li>漏斗步驟完成（註冊 → 啟用 → 付費 → 續約的每一步）&lt;/li>
&lt;li>功能使用頻率（哪些功能被頻繁使用、哪些從未被觸發）&lt;/li>
&lt;li>Session 長度和頻率（使用者多常用、每次用多久）&lt;/li>
&lt;li>關鍵轉換事件（首次付費、邀請好友、升級方案）&lt;/li>
&lt;/ul>
&lt;h3 id="事件表-1">事件表&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>事件名稱&lt;/th>
 &lt;th>類型&lt;/th>
 &lt;th>觸發時機&lt;/th>
 &lt;th>data schema 重點&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>funnel.{name}.step_N&lt;/td>
 &lt;td>event&lt;/td>
 &lt;td>漏斗步驟完成&lt;/td>
 &lt;td>step_name, funnel_name&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>feature.{name}.used&lt;/td>
 &lt;td>event&lt;/td>
 &lt;td>使用者使用特定功能&lt;/td>
 &lt;td>feature_name, context&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>session.start / session.end&lt;/td>
 &lt;td>lifecycle&lt;/td>
 &lt;td>session 邊界&lt;/td>
 &lt;td>session_duration&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>conversion.{type}&lt;/td>
 &lt;td>event&lt;/td>
 &lt;td>關鍵轉換&lt;/td>
 &lt;td>conversion_type, value&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="查詢場景-1">查詢場景&lt;/h3>
&lt;p>&lt;strong>Funnel 轉換率&lt;/strong>：每步的完成數 / 上一步的完成數。SQLite 層做每步計數，PostgreSQL 層做 session 級 JOIN 的精確轉換率（見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇&lt;/a>）。&lt;/p>
&lt;p>&lt;strong>Cohort 留存&lt;/strong>：按「首次使用週」分群，計算每週的回訪率。需要 session.start 事件 + 使用者首次出現的時間戳。&lt;/p>
&lt;p>&lt;strong>功能使用率&lt;/strong>：feature.*.used 事件按 name GROUP BY COUNT，排序找出最常/最少使用的功能。&lt;/p>
&lt;h3 id="生命週期階段-1">生命週期階段&lt;/h3>
&lt;p>上線後啟用。開發期不需要商業事件（沒有真實使用者）。測試期可以用模擬流量驗證 funnel 事件的觸發正確性，但不做分析。&lt;/p>
&lt;h2 id="資安動機">資安動機&lt;/h2>
&lt;p>資安動機驅動的事件收集目標是「偵測非預期的存取模式、追蹤敏感操作、提供事後稽核的 audit trail」。&lt;/p></description><content:encoded><![CDATA[<p>事件設計是三維結構：動機（為什麼收）決定需要什麼事件、感測器（怎麼收）決定在前端哪裡埋點、生命週期（什麼時候收）決定各事件在哪個產品階段啟用。本章展開<a href="/blog/monitoring/01-mental-model/derive-collection-from-requirements/" data-link-title="從需求推導「該收集哪些事件」" data-link-desc="從 debug 需求、行為分析需求、效能需求、合規需求四個方向推導事件收集策略 — 避免「什麼都收」和「什麼都不收」">從需求推導收集策略</a>的四個方向到具體事件名稱級。從動機出發反推事件清單，比從技術能力出發（「SDK 能收什麼就收什麼」）更精準 — 每個事件都能回指一個具體的消費場景。</p>
<h2 id="debug-動機">Debug 動機</h2>
<p>Debug 動機驅動的事件收集目標是「問題發生時、開發者能從事件記錄中重建 context 並定位根因」。</p>
<h3 id="要偵測的行為">要偵測的行為</h3>
<ul>
<li>多步驟流程的每一步完成或失敗（連線 → 認證 → 資料交換）</li>
<li>系統狀態轉換（前景/背景、連線/斷線、登入/登出）</li>
<li>非預期例外（uncaught exception、network error、timeout）</li>
<li>使用者最近的操作序列（問題發生前做了什麼）</li>
</ul>
<h3 id="事件表">事件表</h3>
<table>
  <thead>
      <tr>
          <th>事件名稱</th>
          <th>類型</th>
          <th>觸發時機</th>
          <th>data schema 重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>{feature}.step.done</td>
          <td>lifecycle</td>
          <td>流程步驟完成</td>
          <td>step_name, duration_ms</td>
      </tr>
      <tr>
          <td>{feature}.step.failed</td>
          <td>error</td>
          <td>流程步驟失敗</td>
          <td>step_name, error, context</td>
      </tr>
      <tr>
          <td>app.exception</td>
          <td>error</td>
          <td>uncaught exception</td>
          <td>message, stack_trace, component</td>
      </tr>
      <tr>
          <td>ws.connected / ws.disconnected</td>
          <td>lifecycle</td>
          <td>連線狀態變化</td>
          <td>url, reason, code</td>
      </tr>
      <tr>
          <td>app.foreground / app.background</td>
          <td>lifecycle</td>
          <td>app 前後景切換</td>
          <td>duration_in_background</td>
      </tr>
      <tr>
          <td>{action}.completed</td>
          <td>event</td>
          <td>使用者完成操作</td>
          <td>action_detail</td>
      </tr>
  </tbody>
</table>
<h3 id="查詢場景">查詢場景</h3>
<p><strong>Session 回放</strong>：按 session_id 過濾、按時間排序，還原「使用者做了什麼 → 系統發生了什麼 → 問題在哪裡出現」。</p>
<p><strong>Error 根因定位</strong>：按 error name GROUP BY，找出最常出現的錯誤。單筆 error 的 stack_trace + 同 session 的 lifecycle 事件組合，判斷失敗發生在流程的哪一步。</p>
<p><strong>最近 N 個操作</strong>：error 發生前的 10-20 個 event/lifecycle 事件，等同 Sentry 的 breadcrumb trail。</p>
<h3 id="生命週期階段">生命週期階段</h3>
<p>開發期起全開。Debug 事件是最早需要的 — 實機測試階段就依賴這些事件定位問題。error 類和 lifecycle 類不做取樣（量低且每筆都可能是線索）。</p>
<h2 id="商業動機">商業動機</h2>
<p>商業動機驅動的事件收集目標是「回答產品決策的問題 — 使用者在哪裡流失、不同群組行為有什麼差異、哪些功能被使用」。</p>
<h3 id="要偵測的行為-1">要偵測的行為</h3>
<ul>
<li>漏斗步驟完成（註冊 → 啟用 → 付費 → 續約的每一步）</li>
<li>功能使用頻率（哪些功能被頻繁使用、哪些從未被觸發）</li>
<li>Session 長度和頻率（使用者多常用、每次用多久）</li>
<li>關鍵轉換事件（首次付費、邀請好友、升級方案）</li>
</ul>
<h3 id="事件表-1">事件表</h3>
<table>
  <thead>
      <tr>
          <th>事件名稱</th>
          <th>類型</th>
          <th>觸發時機</th>
          <th>data schema 重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>funnel.{name}.step_N</td>
          <td>event</td>
          <td>漏斗步驟完成</td>
          <td>step_name, funnel_name</td>
      </tr>
      <tr>
          <td>feature.{name}.used</td>
          <td>event</td>
          <td>使用者使用特定功能</td>
          <td>feature_name, context</td>
      </tr>
      <tr>
          <td>session.start / session.end</td>
          <td>lifecycle</td>
          <td>session 邊界</td>
          <td>session_duration</td>
      </tr>
      <tr>
          <td>conversion.{type}</td>
          <td>event</td>
          <td>關鍵轉換</td>
          <td>conversion_type, value</td>
      </tr>
  </tbody>
</table>
<h3 id="查詢場景-1">查詢場景</h3>
<p><strong>Funnel 轉換率</strong>：每步的完成數 / 上一步的完成數。SQLite 層做每步計數，PostgreSQL 層做 session 級 JOIN 的精確轉換率（見 <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a>）。</p>
<p><strong>Cohort 留存</strong>：按「首次使用週」分群，計算每週的回訪率。需要 session.start 事件 + 使用者首次出現的時間戳。</p>
<p><strong>功能使用率</strong>：feature.*.used 事件按 name GROUP BY COUNT，排序找出最常/最少使用的功能。</p>
<h3 id="生命週期階段-1">生命週期階段</h3>
<p>上線後啟用。開發期不需要商業事件（沒有真實使用者）。測試期可以用模擬流量驗證 funnel 事件的觸發正確性，但不做分析。</p>
<h2 id="資安動機">資安動機</h2>
<p>資安動機驅動的事件收集目標是「偵測非預期的存取模式、追蹤敏感操作、提供事後稽核的 audit trail」。</p>
<h3 id="要偵測的行為-2">要偵測的行為</h3>
<ul>
<li>認證失敗（密碼錯誤、biometric 失敗、token 過期）</li>
<li>權限越界嘗試（嘗試存取非自己的資源、呼叫無權限的 API）</li>
<li>敏感資料存取（查看個資、匯出資料、修改權限設定）</li>
<li>異常存取模式（短時間大量請求、非常規時段存取、來源 IP 變化）</li>
</ul>
<h3 id="事件表-2">事件表</h3>
<table>
  <thead>
      <tr>
          <th>事件名稱</th>
          <th>類型</th>
          <th>觸發時機</th>
          <th>data schema 重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>auth.{method}.failed</td>
          <td>error</td>
          <td>認證失敗</td>
          <td>method, failure_reason, attempt_count</td>
      </tr>
      <tr>
          <td>auth.{method}.success</td>
          <td>event</td>
          <td>認證成功（語意上是系統回呼、歸為 event 是業界慣例）</td>
          <td>method, duration_ms</td>
      </tr>
      <tr>
          <td>authz.denied</td>
          <td>error</td>
          <td>權限檢查拒絕</td>
          <td>resource, action, role</td>
      </tr>
      <tr>
          <td>sensitive.accessed</td>
          <td>event</td>
          <td>敏感資料被存取</td>
          <td>resource_type, accessor_role</td>
      </tr>
      <tr>
          <td>sensitive.exported</td>
          <td>event</td>
          <td>資料被匯出</td>
          <td>export_format, record_count</td>
      </tr>
      <tr>
          <td>admin.setting.changed</td>
          <td>event</td>
          <td>管理設定變更</td>
          <td>setting_key, old_value_hash, new_value_hash</td>
      </tr>
  </tbody>
</table>
<h3 id="查詢場景-2">查詢場景</h3>
<p><strong>認證失敗監控</strong>：auth.*.failed 事件的 count by session_id，短時間內同一 session 多次失敗 → 暴力破解嫌疑。Rule engine 設閾值告警。</p>
<p><strong>Audit trail</strong>：sensitive.* 和 admin.* 事件按時間排列，回答「誰在什麼時候存取/修改了什麼」。合規審計的必要紀錄。</p>
<p><strong>異常 pattern 偵測</strong>：auth 成功後的操作事件頻率和模式分析。正常使用者每 session 操作 10-50 次；自動化腳本可能操作數千次。</p>
<h3 id="生命週期階段-2">生命週期階段</h3>
<p>開發期起全開。安全事件不能延後 — 「先不收安全事件、上線後再加」等於安全審計的空白期。認證相關事件是 auto-intercept 的一部分（見 <a href="/blog/monitoring/03-sdk-design/auto-intercept/" data-link-title="自動攔截機制" data-link-desc="JS window.onerror / Flutter FlutterError.onError / Python sys.excepthook — 各平台攔截未捕獲例外的機制和限制">自動攔截機制</a>），不需要手動埋點。</p>
<h3 id="和-redaction-的關係">和 redaction 的關係</h3>
<p>資安事件本身可能包含敏感資訊（失敗的密碼、被存取的個資欄位名稱）。事件的 data schema 設計時標記需要 <a href="/blog/monitoring/knowledge-cards/redaction/" data-link-title="Redaction" data-link-desc="說明在事件資料離開 client 之前把敏感欄位的值替換成遮罩或移除的機制">redaction</a> 的欄位 — auth.failed 記錄失敗原因但不記錄輸入的密碼、sensitive.accessed 記錄資源類型但不記錄資源內容。</p>
<h2 id="效能動機">效能動機</h2>
<p>效能動機驅動的事件收集目標是「發現效能退化趨勢、定位效能瓶頸、為容量規劃提供數據」。</p>
<h3 id="要偵測的行為-3">要偵測的行為</h3>
<ul>
<li>操作回應時間（API 呼叫、頁面載入、動畫轉場）</li>
<li>渲染效能（frame rate、長任務、佈局重排）</li>
<li>資源使用（記憶體、CPU、網路流量）</li>
<li>外部依賴延遲（第三方 API、CDN、資料庫查詢）</li>
</ul>
<h3 id="事件表-3">事件表</h3>
<table>
  <thead>
      <tr>
          <th>事件名稱</th>
          <th>類型</th>
          <th>觸發時機</th>
          <th>data schema 重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>{operation}.duration</td>
          <td>metric</td>
          <td>操作完成</td>
          <td>duration_ms, operation_name</td>
      </tr>
      <tr>
          <td>render.frame_drop</td>
          <td>metric</td>
          <td>掉幀偵測</td>
          <td>dropped_frames, total_frames</td>
      </tr>
      <tr>
          <td>resource.memory</td>
          <td>metric</td>
          <td>定期取樣（30s）</td>
          <td>heap_used, heap_total</td>
      </tr>
      <tr>
          <td>dependency.{name}.latency</td>
          <td>metric</td>
          <td>外部呼叫完成</td>
          <td>dependency_name, latency_ms, status</td>
      </tr>
      <tr>
          <td>web.vitals</td>
          <td>metric</td>
          <td>Web 頁面載入</td>
          <td>lcp_ms, fid_ms, cls_score</td>
      </tr>
  </tbody>
</table>
<h3 id="查詢場景-3">查詢場景</h3>
<p><strong>P95 趨勢</strong>：{operation}.duration 事件按天聚合、計算 percentile_cont(0.95)，觀察回應時間是否隨版本增加。</p>
<p><strong>容量規劃</strong>：resource.memory 事件的趨勢圖，判斷記憶體是否隨使用時間穩定增長（memory leak 訊號）。</p>
<p><strong>依賴健康度</strong>：dependency.*.latency 事件按 dependency_name GROUP BY，比較各依賴的平均延遲和失敗率。</p>
<h3 id="生命週期階段-3">生命週期階段</h3>
<p>測試期起啟用。開發期不需要效能事件（本地環境的效能數據不代表 production）。測試期啟用用於建立效能 baseline。上線後持續收集用於趨勢監控。</p>
<p>效能事件量通常最大（每 30 秒一筆 resource.memory × 活躍使用者數），取樣率需要控制 — 自用場景全收、商業產品取樣 10-50%（見 <a href="/blog/monitoring/03-sdk-design/frontend-sensor-design/" data-link-title="前端感測器設計" data-link-desc="什麼行為值得埋感測器、每類感測器的實作方式、取樣策略和效能影響 — 和 auto-intercept 的被動攔截互補">前端感測器設計</a> 的取樣策略段）。</p>
<h2 id="ab-測試動機">A/B 測試動機</h2>
<p>A/B 測試動機驅動的事件是商業動機的延伸 — 實驗期間收集實驗分組和轉換事件，實驗結束後關閉。</p>
<h3 id="事件表-4">事件表</h3>
<table>
  <thead>
      <tr>
          <th>事件名稱</th>
          <th>類型</th>
          <th>觸發時機</th>
          <th>data schema 重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>experiment.{name}.assigned</td>
          <td>event</td>
          <td>使用者被分配到實驗組</td>
          <td>experiment_name, variant</td>
      </tr>
      <tr>
          <td>experiment.{name}.converted</td>
          <td>event</td>
          <td>使用者完成轉換目標</td>
          <td>experiment_name, variant, conversion_type</td>
      </tr>
  </tbody>
</table>
<h3 id="生命週期階段-4">生命週期階段</h3>
<p>實驗期間啟用，實驗結束後關閉（從 SDK config 或 feature flag 移除）。實驗事件的保留期限跟著實驗週期走 — 實驗結束 + 分析完成後可清除。A/B test 的統計分析見 <a href="/blog/monitoring/08-business-analytics/ab-test-statistics/" data-link-title="A/B Test 的統計基礎" data-link-desc="假設檢定、樣本量計算、多重比較校正 — A/B test 不只是「比較兩個數字」，統計方法決定結論是否可靠">A/B test 的統計基礎</a>。</p>
<h2 id="完整對照總表">完整對照總表</h2>
<table>
  <thead>
      <tr>
          <th>動機</th>
          <th>要偵測的行為</th>
          <th>事件名稱模式</th>
          <th>感測器類型</th>
          <th>生命週期啟用</th>
          <th>查詢模式</th>
          <th>保留層級</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Debug</td>
          <td>流程步驟完成/失敗</td>
          <td>{feature}.step.*</td>
          <td>auto-intercept</td>
          <td>開發期起</td>
          <td>session 回放</td>
          <td>原始 7d</td>
      </tr>
      <tr>
          <td>Debug</td>
          <td>例外拋出</td>
          <td>app.exception</td>
          <td>auto-intercept</td>
          <td>開發期起</td>
          <td>error GROUP BY</td>
          <td>原始 30d</td>
      </tr>
      <tr>
          <td>Debug</td>
          <td>連線狀態</td>
          <td>ws.connected/disconnected</td>
          <td>auto-intercept</td>
          <td>開發期起</td>
          <td>session 回放</td>
          <td>原始 7d</td>
      </tr>
      <tr>
          <td>Debug</td>
          <td>最近操作</td>
          <td>{action}.completed</td>
          <td>手動埋點</td>
          <td>開發期起</td>
          <td>breadcrumb trail</td>
          <td>原始 7d</td>
      </tr>
      <tr>
          <td>商業</td>
          <td>漏斗步驟</td>
          <td>funnel.{name}.step_N</td>
          <td>手動埋點</td>
          <td>上線後</td>
          <td>funnel JOIN</td>
          <td>小時聚合 90d</td>
      </tr>
      <tr>
          <td>商業</td>
          <td>功能使用</td>
          <td>feature.{name}.used</td>
          <td>手動埋點</td>
          <td>上線後</td>
          <td>COUNT GROUP BY</td>
          <td>天聚合 365d</td>
      </tr>
      <tr>
          <td>商業</td>
          <td>Session</td>
          <td>session.start/end</td>
          <td>auto-intercept</td>
          <td>上線後</td>
          <td>cohort 留存</td>
          <td>天聚合 365d</td>
      </tr>
      <tr>
          <td>商業</td>
          <td>轉換</td>
          <td>conversion.{type}</td>
          <td>手動埋點</td>
          <td>上線後</td>
          <td>funnel 最後一步</td>
          <td>原始 90d</td>
      </tr>
      <tr>
          <td>資安</td>
          <td>認證失敗</td>
          <td>auth.{method}.failed</td>
          <td>auto-intercept</td>
          <td>開發期起</td>
          <td>閾值告警</td>
          <td>原始 30d</td>
      </tr>
      <tr>
          <td>資安</td>
          <td>權限拒絕</td>
          <td>authz.denied</td>
          <td>auto-intercept</td>
          <td>開發期起</td>
          <td>pattern 偵測</td>
          <td>原始 30d</td>
      </tr>
      <tr>
          <td>資安</td>
          <td>敏感存取</td>
          <td>sensitive.*</td>
          <td>手動埋點</td>
          <td>開發期起</td>
          <td>audit trail</td>
          <td>原始 365d</td>
      </tr>
      <tr>
          <td>資安</td>
          <td>設定變更</td>
          <td>admin.setting.changed</td>
          <td>手動埋點</td>
          <td>開發期起</td>
          <td>audit trail</td>
          <td>原始 365d</td>
      </tr>
      <tr>
          <td>效能</td>
          <td>操作延遲</td>
          <td>{operation}.duration</td>
          <td>手動埋點</td>
          <td>測試期起</td>
          <td>P95 趨勢</td>
          <td>小時聚合 90d</td>
      </tr>
      <tr>
          <td>效能</td>
          <td>渲染效能</td>
          <td>render.frame_drop</td>
          <td>auto-intercept</td>
          <td>測試期起</td>
          <td>趨勢圖</td>
          <td>小時聚合 90d</td>
      </tr>
      <tr>
          <td>效能</td>
          <td>資源用量</td>
          <td>resource.memory</td>
          <td>定期取樣</td>
          <td>測試期起</td>
          <td>趨勢圖</td>
          <td>小時聚合 90d</td>
      </tr>
      <tr>
          <td>效能</td>
          <td>外部依賴</td>
          <td>dependency.{name}.latency</td>
          <td>手動埋點</td>
          <td>測試期起</td>
          <td>GROUP BY 依賴</td>
          <td>小時聚合 90d</td>
      </tr>
      <tr>
          <td>效能</td>
          <td>Web Vitals</td>
          <td>web.vitals</td>
          <td>auto-intercept</td>
          <td>測試期起</td>
          <td>趨勢圖</td>
          <td>小時聚合 90d</td>
      </tr>
      <tr>
          <td>A/B</td>
          <td>實驗分組</td>
          <td>experiment.{name}.assigned</td>
          <td>手動埋點</td>
          <td>實驗期間</td>
          <td>variant GROUP BY</td>
          <td>實驗結束後清</td>
      </tr>
      <tr>
          <td>A/B</td>
          <td>實驗轉換</td>
          <td>experiment.{name}.converted</td>
          <td>手動埋點</td>
          <td>實驗期間</td>
          <td>轉換率計算</td>
          <td>實驗結束後清</td>
      </tr>
      <tr>
          <td>DevOps</td>
          <td>Collector 存活</td>
          <td>collector.health.check</td>
          <td>Collector 內部</td>
          <td>開發期起</td>
          <td>狀態卡</td>
          <td>原始 7d</td>
      </tr>
      <tr>
          <td>DevOps</td>
          <td>事件吞吐量</td>
          <td>collector.ingestion.count</td>
          <td>Collector 內部</td>
          <td>開發期起</td>
          <td>吞吐曲線</td>
          <td>小時聚合 90d</td>
      </tr>
      <tr>
          <td>DevOps</td>
          <td>儲存用量</td>
          <td>collector.storage.disk_usage</td>
          <td>Collector 內部</td>
          <td>開發期起</td>
          <td>儲存圖</td>
          <td>小時聚合 90d</td>
      </tr>
      <tr>
          <td>DevOps</td>
          <td>SDK 心跳</td>
          <td>sdk.heartbeat</td>
          <td>SDK 端</td>
          <td>開發期起</td>
          <td>連線列表</td>
          <td>原始 7d</td>
      </tr>
      <tr>
          <td>DevOps</td>
          <td>部署事件</td>
          <td>deployment.completed</td>
          <td>CI/CD hook</td>
          <td>開發期起</td>
          <td>部署狀態</td>
          <td>原始 30d</td>
      </tr>
      <tr>
          <td>DevOps</td>
          <td>規則命中</td>
          <td>rule.matched</td>
          <td>Collector 內部</td>
          <td>開發期起</td>
          <td>alert 歷史</td>
          <td>原始 30d</td>
      </tr>
      <tr>
          <td>中台</td>
          <td>使用者首次出現</td>
          <td>user.first_seen</td>
          <td>Collector 計算</td>
          <td>上線後</td>
          <td>cohort 分群</td>
          <td>天聚合 365d</td>
      </tr>
      <tr>
          <td>中台</td>
          <td>通路歸因</td>
          <td>attribution.install_source</td>
          <td>SDK 首次啟動</td>
          <td>上線後</td>
          <td>歸因報表</td>
          <td>原始 90d</td>
      </tr>
      <tr>
          <td>中台</td>
          <td>即時在線</td>
          <td>session.active.count</td>
          <td>Collector 計算</td>
          <td>上線後</td>
          <td>即時大屏</td>
          <td>小時聚合 90d</td>
      </tr>
  </tbody>
</table>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>四類事件的基礎定義 → <a href="/blog/monitoring/01-mental-model/four-event-types/" data-link-title="四類事件的完整定義" data-link-desc="Event / Error / Metric / Lifecycle 四類事件各自的語意、觸發時機和典型用途 — 分類是監控體系的統一語言">四類事件的完整定義</a></li>
<li>事件枚舉的方法論 → <a href="/blog/monitoring/01-mental-model/event-enumeration-method/" data-link-title="事件枚舉與補齊檢查" data-link-desc="從操作盤點系統性地推導出完整的事件清單 — 四類補齊檢查確保沒有遺漏、粒度判準確保每個事件只記一個事實">事件枚舉與補齊檢查</a></li>
<li>前端感測器的具體設計 → <a href="/blog/monitoring/03-sdk-design/frontend-sensor-design/" data-link-title="前端感測器設計" data-link-desc="什麼行為值得埋感測器、每類感測器的實作方式、取樣策略和效能影響 — 和 auto-intercept 的被動攔截互補">前端感測器設計</a></li>
<li>感測器的生命週期控制 → <a href="/blog/monitoring/03-sdk-design/sensor-lifecycle-management/" data-link-title="感測器生命週期管理" data-link-desc="產品生命週期的五個階段各啟用什麼感測器 — feature flag 整合、取樣率動態調整、感測器開關的可觀察性">感測器生命週期管理</a></li>
<li>查詢消費模式的完整展開 → <a href="/blog/monitoring/04-collector/query-consumption-patterns/" data-link-title="查詢消費模式" data-link-desc="Debug / Alerting / 產品決策 / 安全審計 / 效能監控 — 五種查詢場景各需要什麼事件、什麼欄位、什麼查詢模式">查詢消費模式</a></li>
</ul>
]]></content:encoded></item><item><title>推薦系統概論</title><link>https://tarrragon.github.io/blog/monitoring/08-business-analytics/recommendation-overview/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/08-business-analytics/recommendation-overview/</guid><description>&lt;p>推薦系統從使用者的歷史行為或物品的內容特徵推斷使用者可能感興趣的物品。三種基本方法各自依賴不同的資料：collaborative filtering 依賴使用者行為矩陣，content-based 依賴物品特徵，混合方法結合兩者。&lt;/p>
&lt;h2 id="collaborative-filtering">Collaborative Filtering&lt;/h2>
&lt;p>Collaborative filtering 的核心假設是「和你行為相似的人喜歡的東西，你也可能喜歡」。它不分析物品的內容，只看使用者的行為模式。&lt;/p>
&lt;h3 id="user-based">User-based&lt;/h3>
&lt;p>找到和目標使用者行為最相似的一群人（neighbors），把 neighbors 互動過但目標使用者沒互動過的物品推薦給目標使用者。&lt;/p>
&lt;p>資料需求：使用者-物品互動矩陣（user × item matrix），每格是評分、點擊、購買等互動訊號。&lt;/p>
&lt;p>挑戰：使用者數量增加時，計算使用者相似度的成本呈平方增長。冷啟動問題 — 新使用者沒有足夠的互動歷史來找到 neighbors。&lt;/p>
&lt;h3 id="item-based">Item-based&lt;/h3>
&lt;p>計算物品之間的相似度（根據哪些使用者同時互動過這兩個物品），推薦和使用者已互動物品相似的物品。&lt;/p>
&lt;p>資料需求：同上，但相似度計算的維度是物品而非使用者。&lt;/p>
&lt;p>優勢：物品的相似度矩陣比使用者相似度穩定（物品不會突然改變行為，使用者會），可以離線計算和快取。Amazon 的「購買此商品的人也買了」就是 item-based collaborative filtering。&lt;/p>
&lt;h2 id="content-based-filtering">Content-based Filtering&lt;/h2>
&lt;p>Content-based filtering 分析物品的內容特徵，推薦和使用者過去喜歡的物品內容相似的物品。&lt;/p>
&lt;p>資料需求：每個物品的特徵向量（genre、author、price range、keywords）和使用者的偏好 profile（從歷史互動推斷）。&lt;/p>
&lt;p>優勢：不依賴其他使用者的行為 — 單一使用者就能產生推薦。新物品只要有特徵描述就能被推薦（解決 collaborative filtering 的新物品冷啟動）。&lt;/p>
&lt;p>挑戰：推薦結果傾向和使用者歷史行為相似，缺乏意外發現（serendipity）。特徵工程的品質直接影響推薦品質 — 物品的特徵描述不完整或不準確，推薦就不準確。&lt;/p>
&lt;h2 id="混合方法">混合方法&lt;/h2>
&lt;p>結合 collaborative filtering 和 content-based 的優勢，減少各自的弱點。&lt;/p>
&lt;h3 id="加權混合">加權混合&lt;/h3>
&lt;p>兩種方法各自產生推薦清單，用加權分數合併。權重可以固定，也可以根據情境動態調整（新使用者偏重 content-based，老使用者偏重 collaborative filtering）。&lt;/p>
&lt;h3 id="特徵增強">特徵增強&lt;/h3>
&lt;p>用 content-based 的特徵增強 collaborative filtering 的矩陣。使用者-物品互動矩陣加上物品的內容特徵，讓相似度計算同時考慮行為和內容。&lt;/p>
&lt;h3 id="級聯">級聯&lt;/h3>
&lt;p>先用一種方法粗篩，再用另一種方法排序。Collaborative filtering 產生候選清單，content-based 根據使用者的內容偏好排序。&lt;/p>
&lt;h2 id="行為事件在推薦系統的角色">行為事件在推薦系統的角色&lt;/h2>
&lt;p>推薦系統的輸入是使用者的互動行為 — 瀏覽、點擊、加入購物車、購買、評分。這些互動行為就是行為事件（&lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/" data-link-title="模組一：監控心智模型" data-link-desc="四類事件（event / error / metric / lifecycle）的分類與收集策略">模組一 心智模型&lt;/a>）的 event 類型。&lt;/p>
&lt;p>行為事件的設計直接影響推薦系統的資料品質。事件的粒度決定了推薦的精細度 — 只記錄「頁面瀏覽」比記錄「頁面瀏覽 + 停留時間 + 滾動深度」的推薦信號弱。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>使用者分群的工程實作 → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/rfm-segmentation/" data-link-title="RFM 分群" data-link-desc="Recency / Frequency / Monetary 三維度的使用者分群 — 從行為事件計算 RFM 分數、定義使用者群體、驅動差異化策略">RFM 分群&lt;/a>&lt;/li>
&lt;li>行為事件設計 → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/behavior-event-design/" data-link-title="行為事件設計" data-link-desc="事件命名規範、屬性設計、funnel 定義 — 行為分析的品質取決於事件設計的品質">行為事件設計&lt;/a>&lt;/li>
&lt;li>自架方案的分析能力邊界 → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/self-hosted-funnel/" data-link-title="從 collector 資料做基礎 funnel 分析" data-link-desc="SQLite 層能做什麼程度的 funnel、PostgreSQL 層提供什麼進階能力、JSONL 匯出後的臨時分析">從 collector 資料做基礎 funnel 分析&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>推薦系統從使用者的歷史行為或物品的內容特徵推斷使用者可能感興趣的物品。三種基本方法各自依賴不同的資料：collaborative filtering 依賴使用者行為矩陣，content-based 依賴物品特徵，混合方法結合兩者。</p>
<h2 id="collaborative-filtering">Collaborative Filtering</h2>
<p>Collaborative filtering 的核心假設是「和你行為相似的人喜歡的東西，你也可能喜歡」。它不分析物品的內容，只看使用者的行為模式。</p>
<h3 id="user-based">User-based</h3>
<p>找到和目標使用者行為最相似的一群人（neighbors），把 neighbors 互動過但目標使用者沒互動過的物品推薦給目標使用者。</p>
<p>資料需求：使用者-物品互動矩陣（user × item matrix），每格是評分、點擊、購買等互動訊號。</p>
<p>挑戰：使用者數量增加時，計算使用者相似度的成本呈平方增長。冷啟動問題 — 新使用者沒有足夠的互動歷史來找到 neighbors。</p>
<h3 id="item-based">Item-based</h3>
<p>計算物品之間的相似度（根據哪些使用者同時互動過這兩個物品），推薦和使用者已互動物品相似的物品。</p>
<p>資料需求：同上，但相似度計算的維度是物品而非使用者。</p>
<p>優勢：物品的相似度矩陣比使用者相似度穩定（物品不會突然改變行為，使用者會），可以離線計算和快取。Amazon 的「購買此商品的人也買了」就是 item-based collaborative filtering。</p>
<h2 id="content-based-filtering">Content-based Filtering</h2>
<p>Content-based filtering 分析物品的內容特徵，推薦和使用者過去喜歡的物品內容相似的物品。</p>
<p>資料需求：每個物品的特徵向量（genre、author、price range、keywords）和使用者的偏好 profile（從歷史互動推斷）。</p>
<p>優勢：不依賴其他使用者的行為 — 單一使用者就能產生推薦。新物品只要有特徵描述就能被推薦（解決 collaborative filtering 的新物品冷啟動）。</p>
<p>挑戰：推薦結果傾向和使用者歷史行為相似，缺乏意外發現（serendipity）。特徵工程的品質直接影響推薦品質 — 物品的特徵描述不完整或不準確，推薦就不準確。</p>
<h2 id="混合方法">混合方法</h2>
<p>結合 collaborative filtering 和 content-based 的優勢，減少各自的弱點。</p>
<h3 id="加權混合">加權混合</h3>
<p>兩種方法各自產生推薦清單，用加權分數合併。權重可以固定，也可以根據情境動態調整（新使用者偏重 content-based，老使用者偏重 collaborative filtering）。</p>
<h3 id="特徵增強">特徵增強</h3>
<p>用 content-based 的特徵增強 collaborative filtering 的矩陣。使用者-物品互動矩陣加上物品的內容特徵，讓相似度計算同時考慮行為和內容。</p>
<h3 id="級聯">級聯</h3>
<p>先用一種方法粗篩，再用另一種方法排序。Collaborative filtering 產生候選清單，content-based 根據使用者的內容偏好排序。</p>
<h2 id="行為事件在推薦系統的角色">行為事件在推薦系統的角色</h2>
<p>推薦系統的輸入是使用者的互動行為 — 瀏覽、點擊、加入購物車、購買、評分。這些互動行為就是行為事件（<a href="/blog/monitoring/01-mental-model/" data-link-title="模組一：監控心智模型" data-link-desc="四類事件（event / error / metric / lifecycle）的分類與收集策略">模組一 心智模型</a>）的 event 類型。</p>
<p>行為事件的設計直接影響推薦系統的資料品質。事件的粒度決定了推薦的精細度 — 只記錄「頁面瀏覽」比記錄「頁面瀏覽 + 停留時間 + 滾動深度」的推薦信號弱。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>使用者分群的工程實作 → <a href="/blog/monitoring/08-business-analytics/rfm-segmentation/" data-link-title="RFM 分群" data-link-desc="Recency / Frequency / Monetary 三維度的使用者分群 — 從行為事件計算 RFM 分數、定義使用者群體、驅動差異化策略">RFM 分群</a></li>
<li>行為事件設計 → <a href="/blog/monitoring/08-business-analytics/behavior-event-design/" data-link-title="行為事件設計" data-link-desc="事件命名規範、屬性設計、funnel 定義 — 行為分析的品質取決於事件設計的品質">行為事件設計</a></li>
<li>自架方案的分析能力邊界 → <a href="/blog/monitoring/08-business-analytics/self-hosted-funnel/" data-link-title="從 collector 資料做基礎 funnel 分析" data-link-desc="SQLite 層能做什麼程度的 funnel、PostgreSQL 層提供什麼進階能力、JSONL 匯出後的臨時分析">從 collector 資料做基礎 funnel 分析</a></li>
</ul>
]]></content:encoded></item><item><title>監控資料洩漏的 Threat Model</title><link>https://tarrragon.github.io/blog/monitoring/07-security-privacy/monitoring-data-threat-model/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/07-security-privacy/monitoring-data-threat-model/</guid><description>&lt;p>監控系統收集的資料本身就是有價值的攻擊目標。Error 訊息包含 stack trace 和系統架構資訊，event 資料包含使用者行為模式，lifecycle 資料包含部署時程和系統狀態。攻擊者取得這些資料後可以用於進一步的攻擊 — stack trace 揭露程式碼結構，部署資訊揭露更新節奏，行為資料揭露高價值使用者。&lt;/p>
&lt;h2 id="威脅場景一傳輸竊聽">威脅場景一：傳輸竊聽&lt;/h2>
&lt;h3 id="攻擊方式">攻擊方式&lt;/h3>
&lt;p>攻擊者在 SDK 和 collector 之間的網路路徑上攔截未加密的 HTTP 流量。同網段的 ARP spoofing、WiFi sniffing、或中間人（MITM）proxy。&lt;/p>
&lt;h3 id="暴露的資料">暴露的資料&lt;/h3>
&lt;p>事件的完整 JSON payload — 包括 redaction 後殘留的資訊（使用者行為、系統狀態、error message）。API key 或 basic auth credential 如果在 HTTP header 中明文傳送，也會被攔截。&lt;/p>
&lt;h3 id="防護">防護&lt;/h3>
&lt;p>使用 HTTPS 加密傳輸（&lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全&lt;/a>）。所有 SDK 到 collector 的通訊走 TLS — 自簽憑證在自用場景足夠，公開部署用 Let&amp;rsquo;s Encrypt。&lt;/p>
&lt;h2 id="威脅場景二儲存入侵">威脅場景二：儲存入侵&lt;/h2>
&lt;h3 id="攻擊方式-1">攻擊方式&lt;/h3>
&lt;p>攻擊者取得 collector server 的存取權限（SSH 入侵、容器逃逸、雲端 IAM 權限提升），直接讀取儲存的事件檔案。&lt;/p>
&lt;h3 id="暴露的資料-1">暴露的資料&lt;/h3>
&lt;p>所有歷史事件 — 包含 redaction 處理後的事件。如果 redaction 不完整（遺漏了某些敏感欄位），歷史事件中可能包含 secret。&lt;/p>
&lt;h3 id="防護-1">防護&lt;/h3>
&lt;p>&lt;strong>最小化儲存&lt;/strong>：只保留必要期限的資料，過期自動刪除（&lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/gdpr-minimization/" data-link-title="GDPR 最小化原則的工程落地" data-link-desc="資料最小化、目的限制、儲存限制 — GDPR 三個核心原則在監控系統的工程實作方式">GDPR 最小化原則&lt;/a>）。攻擊者能取得的資料量與保留期間成正比。&lt;/p>
&lt;p>&lt;strong>檔案系統加密&lt;/strong>：LUKS（Linux）或 FileVault（macOS）對整個磁碟加密。Server 關機後磁碟資料無法被讀取。&lt;/p>
&lt;p>&lt;strong>access log 監控&lt;/strong>：記錄所有對事件儲存的存取操作（&lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control&lt;/a>）。異常存取（非工作時間、非預期的 IP）觸發告警。&lt;/p>
&lt;h2 id="威脅場景三endpoint-濫用">威脅場景三：Endpoint 濫用&lt;/h2>
&lt;h3 id="攻擊方式-2">攻擊方式&lt;/h3>
&lt;p>攻擊者取得 SDK 的 API key（從 client 端的程式碼或設定檔中提取），大量寫入垃圾事件或惡意 payload。&lt;/p>
&lt;h3 id="影響">影響&lt;/h3>
&lt;p>&lt;strong>資料汙染&lt;/strong>：合法事件和垃圾事件混在一起，分析結果不可靠。&lt;/p>
&lt;p>&lt;strong>資源耗盡&lt;/strong>：大量寫入消耗 collector 的儲存和處理能力。&lt;/p>
&lt;p>&lt;strong>注入攻擊&lt;/strong>：如果 collector 的查詢介面沒有做好輸入驗證，惡意 payload 中的特殊字元可能觸發 injection。&lt;/p>
&lt;h3 id="防護-2">防護&lt;/h3>
&lt;p>&lt;strong>Rate limit&lt;/strong>：每個 API key 的寫入速率限制。正常的 SDK 行為有可預測的寫入頻率（每分鐘 N 個事件），超出正常範圍的寫入被拒絕。&lt;/p>
&lt;p>&lt;strong>Schema validation&lt;/strong>：collector 只接受符合定義 schema 的事件。格式異常的 payload 在寫入前被丟棄。&lt;/p>
&lt;p>&lt;strong>API key 輪替&lt;/strong>：如果 API key 被洩漏，輪替 key 讓舊 key 失效。SDK 端更新新 key 後恢復正常。&lt;/p>
&lt;h2 id="威脅場景四內部越權存取">威脅場景四：內部越權存取&lt;/h2>
&lt;h3 id="攻擊方式-3">攻擊方式&lt;/h3>
&lt;p>有 collector 讀取權限的人（開發者、維運人員）存取超出自己職責範圍的事件資料。例如開發者查看行為分析資料（只應該看 debug 資料），或前端開發者查看 server-side 的 error 事件。&lt;/p>
&lt;h3 id="防護-3">防護&lt;/h3>
&lt;p>&lt;strong>角色分離&lt;/strong>：不同用途的資料用不同的存取權限（&lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control&lt;/a>）。Debug 資料和行為分析資料分開授權。&lt;/p>
&lt;p>&lt;strong>去識別化&lt;/strong>：即使有存取權限，看到的也是去識別化後的資料（&lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/anonymization-strategy/" data-link-title="去識別化策略" data-link-desc="IP 截斷 / user agent 簡化 / stack trace 路徑清理 / session UUID — 四種去識別化技術的適用場景和實作方式">去識別化策略&lt;/a>）。IP 截斷、user agent 簡化、stack trace 路徑清理 — 降低資料的個人可識別性。&lt;/p></description><content:encoded><![CDATA[<p>監控系統收集的資料本身就是有價值的攻擊目標。Error 訊息包含 stack trace 和系統架構資訊，event 資料包含使用者行為模式，lifecycle 資料包含部署時程和系統狀態。攻擊者取得這些資料後可以用於進一步的攻擊 — stack trace 揭露程式碼結構，部署資訊揭露更新節奏，行為資料揭露高價值使用者。</p>
<h2 id="威脅場景一傳輸竊聽">威脅場景一：傳輸竊聽</h2>
<h3 id="攻擊方式">攻擊方式</h3>
<p>攻擊者在 SDK 和 collector 之間的網路路徑上攔截未加密的 HTTP 流量。同網段的 ARP spoofing、WiFi sniffing、或中間人（MITM）proxy。</p>
<h3 id="暴露的資料">暴露的資料</h3>
<p>事件的完整 JSON payload — 包括 redaction 後殘留的資訊（使用者行為、系統狀態、error message）。API key 或 basic auth credential 如果在 HTTP header 中明文傳送，也會被攔截。</p>
<h3 id="防護">防護</h3>
<p>使用 HTTPS 加密傳輸（<a href="/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全</a>）。所有 SDK 到 collector 的通訊走 TLS — 自簽憑證在自用場景足夠，公開部署用 Let&rsquo;s Encrypt。</p>
<h2 id="威脅場景二儲存入侵">威脅場景二：儲存入侵</h2>
<h3 id="攻擊方式-1">攻擊方式</h3>
<p>攻擊者取得 collector server 的存取權限（SSH 入侵、容器逃逸、雲端 IAM 權限提升），直接讀取儲存的事件檔案。</p>
<h3 id="暴露的資料-1">暴露的資料</h3>
<p>所有歷史事件 — 包含 redaction 處理後的事件。如果 redaction 不完整（遺漏了某些敏感欄位），歷史事件中可能包含 secret。</p>
<h3 id="防護-1">防護</h3>
<p><strong>最小化儲存</strong>：只保留必要期限的資料，過期自動刪除（<a href="/blog/monitoring/07-security-privacy/gdpr-minimization/" data-link-title="GDPR 最小化原則的工程落地" data-link-desc="資料最小化、目的限制、儲存限制 — GDPR 三個核心原則在監控系統的工程實作方式">GDPR 最小化原則</a>）。攻擊者能取得的資料量與保留期間成正比。</p>
<p><strong>檔案系統加密</strong>：LUKS（Linux）或 FileVault（macOS）對整個磁碟加密。Server 關機後磁碟資料無法被讀取。</p>
<p><strong>access log 監控</strong>：記錄所有對事件儲存的存取操作（<a href="/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control</a>）。異常存取（非工作時間、非預期的 IP）觸發告警。</p>
<h2 id="威脅場景三endpoint-濫用">威脅場景三：Endpoint 濫用</h2>
<h3 id="攻擊方式-2">攻擊方式</h3>
<p>攻擊者取得 SDK 的 API key（從 client 端的程式碼或設定檔中提取），大量寫入垃圾事件或惡意 payload。</p>
<h3 id="影響">影響</h3>
<p><strong>資料汙染</strong>：合法事件和垃圾事件混在一起，分析結果不可靠。</p>
<p><strong>資源耗盡</strong>：大量寫入消耗 collector 的儲存和處理能力。</p>
<p><strong>注入攻擊</strong>：如果 collector 的查詢介面沒有做好輸入驗證，惡意 payload 中的特殊字元可能觸發 injection。</p>
<h3 id="防護-2">防護</h3>
<p><strong>Rate limit</strong>：每個 API key 的寫入速率限制。正常的 SDK 行為有可預測的寫入頻率（每分鐘 N 個事件），超出正常範圍的寫入被拒絕。</p>
<p><strong>Schema validation</strong>：collector 只接受符合定義 schema 的事件。格式異常的 payload 在寫入前被丟棄。</p>
<p><strong>API key 輪替</strong>：如果 API key 被洩漏，輪替 key 讓舊 key 失效。SDK 端更新新 key 後恢復正常。</p>
<h2 id="威脅場景四內部越權存取">威脅場景四：內部越權存取</h2>
<h3 id="攻擊方式-3">攻擊方式</h3>
<p>有 collector 讀取權限的人（開發者、維運人員）存取超出自己職責範圍的事件資料。例如開發者查看行為分析資料（只應該看 debug 資料），或前端開發者查看 server-side 的 error 事件。</p>
<h3 id="防護-3">防護</h3>
<p><strong>角色分離</strong>：不同用途的資料用不同的存取權限（<a href="/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control</a>）。Debug 資料和行為分析資料分開授權。</p>
<p><strong>去識別化</strong>：即使有存取權限，看到的也是去識別化後的資料（<a href="/blog/monitoring/07-security-privacy/anonymization-strategy/" data-link-title="去識別化策略" data-link-desc="IP 截斷 / user agent 簡化 / stack trace 路徑清理 / session UUID — 四種去識別化技術的適用場景和實作方式">去識別化策略</a>）。IP 截斷、user agent 簡化、stack trace 路徑清理 — 降低資料的個人可識別性。</p>
<p><strong>access log 審計</strong>：所有讀取操作記錄在 access log 中，定期 review。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>SDK 端的 redaction → <a href="/blog/monitoring/07-security-privacy/sdk-redaction-api/" data-link-title="SDK Redaction API 設計" data-link-desc="預設 redaction rule 過濾已知敏感欄位、自訂 pattern 擴展應用特有的 secret 格式 — redaction 在 SDK 端執行，敏感資料不離開 client">SDK Redaction API 設計</a></li>
<li>Transport 層保護 → <a href="/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全</a></li>
<li>Collector 端保護 → <a href="/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control 實作</a></li>
<li>去識別化技術 → <a href="/blog/monitoring/07-security-privacy/anonymization-strategy/" data-link-title="去識別化策略" data-link-desc="IP 截斷 / user agent 簡化 / stack trace 路徑清理 / session UUID — 四種去識別化技術的適用場景和實作方式">去識別化策略</a></li>
<li>Client-side SDK 認證的多層緩解策略 → <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>
</ul>
]]></content:encoded></item><item><title>模組六：商業方案對照</title><link>https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/</guid><description>&lt;p>回答「什麼時候該從自架切換到商業方案」。&lt;/p>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 自架 vs 商業的判斷決策表（使用者數 / 網路範圍 / 功能需求 / 合規要求）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Sentry 深入（error + performance + session replay 的架構）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Firebase 套件（Crashlytics + Analytics + Remote Config 的整合）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Datadog RUM（全棧 APM 的 client-side 觀點）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Mixpanel / Amplitude（行為分析專用 vs 通用監控的差異）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 部署光譜（BaaS + Serverless / PaaS / 完全自架 / 商業 SaaS 四條路徑）&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &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 工具到商業資產的翻轉">monitoring 模組八 商業利用&lt;/a>：商業方案的核心賣點是行為分析功能&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">backend 04 可觀測性&lt;/a>：server-side 商業方案（Datadog / New Relic）的對照&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「什麼時候該從自架切換到商業方案」。</p>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> 自架 vs 商業的判斷決策表（使用者數 / 網路範圍 / 功能需求 / 合規要求）</li>
<li><input checked="" disabled="" type="checkbox"> Sentry 深入（error + performance + session replay 的架構）</li>
<li><input checked="" disabled="" type="checkbox"> Firebase 套件（Crashlytics + Analytics + Remote Config 的整合）</li>
<li><input checked="" disabled="" type="checkbox"> Datadog RUM（全棧 APM 的 client-side 觀點）</li>
<li><input checked="" disabled="" type="checkbox"> Mixpanel / Amplitude（行為分析專用 vs 通用監控的差異）</li>
<li><input checked="" disabled="" type="checkbox"> 部署光譜（BaaS + Serverless / PaaS / 完全自架 / 商業 SaaS 四條路徑）</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">monitoring 模組八 商業利用</a>：商業方案的核心賣點是行為分析功能</li>
<li>→ <a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">backend 04 可觀測性</a>：server-side 商業方案（Datadog / New Relic）的對照</li>
</ul>
]]></content:encoded></item><item><title>Sampling</title><link>https://tarrragon.github.io/blog/monitoring/knowledge-cards/sampling/</link><pubDate>Wed, 24 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/knowledge-cards/sampling/</guid><description>&lt;p>取樣（sampling）的通用概念見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/sampling/" data-link-title="Sampling" data-link-desc="說明觀測資料如何抽樣以控制成本並保留診斷能力">Backend 知識卡：Sampling&lt;/a> — 只保留部分觀測資料以控制成本。本卡聚焦監控 SDK 中的具體實作：在事件產生階段按比例丟棄部分事件，降低後續管線（buffer → transport → collector → storage）的負載。取樣是設計內的損失 — 取樣率是明確的 config 參數，損失量可預測。可先對照 &lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="下游處理能力不足時向上游回傳「慢下來」訊號的流量控制機制 — 監控系統中 collector 用 HTTP 429 向 SDK 傳遞背壓">backpressure&lt;/a>（觸發動態取樣的訊號來源）和 &lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/rate-limiting/" data-link-title="Rate Limiting" data-link-desc="限制每個 client 在單位時間內可送出的事件數量 — 防止單一 SDK bug 或偽造流量消耗整個 collector 的處理能力">rate limiting&lt;/a>（collector 端的 per-client 限制）。&lt;/p>
&lt;h2 id="兩種取樣">兩種取樣&lt;/h2>
&lt;p>&lt;strong>靜態取樣&lt;/strong>：SDK config 中設定固定比例（例如 metric 類 0.1 = 每 10 筆只收 1 筆），在 SDK 整個生命週期保持不變。適合已知高頻但單筆 debug 價值低的事件（render.frame_time、scroll.position）。&lt;/p>
&lt;p>&lt;strong>動態取樣&lt;/strong>：SDK 在收到 collector 的 HTTP 429 後自動降低取樣率，collector 恢復正常後逐步回升。動態取樣在正常情況下不生效（取樣率 = 1.0），只在 collector 過載時啟用。和靜態取樣互補 — 靜態控制基線負載，動態應對突發。&lt;/p>
&lt;h2 id="取樣校正">取樣校正&lt;/h2>
&lt;p>分析時用取樣率還原原始量級。取樣率 0.1 時收到 100 筆事件，推估原始量為 100 / 0.1 = 1000 筆。SDK 端的 &lt;code>sdk.sampling.rate&lt;/code> 指標記錄當前取樣率，讓下游分析知道如何校正。取樣校正對 funnel 和 cohort 分析有效（趨勢和比例不變），對個別事件追蹤無效（被丟棄的事件無法回復）。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>取樣承擔的設計責任是「在可觀測性覆蓋率和系統負載之間找到平衡」。Error 類事件不做取樣（每筆都可能是需要修的 bug），metric 類事件適合高比例取樣（丟幾筆不影響趨勢），event 類和 lifecycle 類取決於分析需求。&lt;/p>
&lt;h2 id="完整章節">完整章節&lt;/h2>
&lt;p>靜態取樣率的設定 → &lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/sensor-lifecycle-management/" data-link-title="感測器生命週期管理" data-link-desc="產品生命週期的五個階段各啟用什麼感測器 — feature flag 整合、取樣率動態調整、感測器開關的可觀察性">感測器生命週期管理&lt;/a>。動態取樣在四層防線中的位置 → &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling&lt;/a>。取樣造成的損失量化和控制 → &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>取樣（sampling）的通用概念見 <a href="/blog/backend/knowledge-cards/sampling/" data-link-title="Sampling" data-link-desc="說明觀測資料如何抽樣以控制成本並保留診斷能力">Backend 知識卡：Sampling</a> — 只保留部分觀測資料以控制成本。本卡聚焦監控 SDK 中的具體實作：在事件產生階段按比例丟棄部分事件，降低後續管線（buffer → transport → collector → storage）的負載。取樣是設計內的損失 — 取樣率是明確的 config 參數，損失量可預測。可先對照 <a href="/blog/monitoring/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="下游處理能力不足時向上游回傳「慢下來」訊號的流量控制機制 — 監控系統中 collector 用 HTTP 429 向 SDK 傳遞背壓">backpressure</a>（觸發動態取樣的訊號來源）和 <a href="/blog/monitoring/knowledge-cards/rate-limiting/" data-link-title="Rate Limiting" data-link-desc="限制每個 client 在單位時間內可送出的事件數量 — 防止單一 SDK bug 或偽造流量消耗整個 collector 的處理能力">rate limiting</a>（collector 端的 per-client 限制）。</p>
<h2 id="兩種取樣">兩種取樣</h2>
<p><strong>靜態取樣</strong>：SDK config 中設定固定比例（例如 metric 類 0.1 = 每 10 筆只收 1 筆），在 SDK 整個生命週期保持不變。適合已知高頻但單筆 debug 價值低的事件（render.frame_time、scroll.position）。</p>
<p><strong>動態取樣</strong>：SDK 在收到 collector 的 HTTP 429 後自動降低取樣率，collector 恢復正常後逐步回升。動態取樣在正常情況下不生效（取樣率 = 1.0），只在 collector 過載時啟用。和靜態取樣互補 — 靜態控制基線負載，動態應對突發。</p>
<h2 id="取樣校正">取樣校正</h2>
<p>分析時用取樣率還原原始量級。取樣率 0.1 時收到 100 筆事件，推估原始量為 100 / 0.1 = 1000 筆。SDK 端的 <code>sdk.sampling.rate</code> 指標記錄當前取樣率，讓下游分析知道如何校正。取樣校正對 funnel 和 cohort 分析有效（趨勢和比例不變），對個別事件追蹤無效（被丟棄的事件無法回復）。</p>
<h2 id="設計責任">設計責任</h2>
<p>取樣承擔的設計責任是「在可觀測性覆蓋率和系統負載之間找到平衡」。Error 類事件不做取樣（每筆都可能是需要修的 bug），metric 類事件適合高比例取樣（丟幾筆不影響趨勢），event 類和 lifecycle 類取決於分析需求。</p>
<h2 id="完整章節">完整章節</h2>
<p>靜態取樣率的設定 → <a href="/blog/monitoring/03-sdk-design/sensor-lifecycle-management/" data-link-title="感測器生命週期管理" data-link-desc="產品生命週期的五個階段各啟用什麼感測器 — feature flag 整合、取樣率動態調整、感測器開關的可觀察性">感測器生命週期管理</a>。動態取樣在四層防線中的位置 → <a href="/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling</a>。取樣造成的損失量化和控制 → <a href="/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性</a>。</p>
]]></content:encoded></item><item><title>查詢消費模式</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/query-consumption-patterns/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/query-consumption-patterns/</guid><description>&lt;p>事件的價值在於被查詢消費。設計事件時反過來想：查詢需要什麼欄位 → 事件需要帶什麼 data → 感測器需要在什麼時機觸發。從消費端反推設計，避免「收了一堆事件但查不到想要的答案」。&lt;/p>
&lt;p>五種查詢場景各自需要不同的事件類型、欄位和查詢模式。每種場景的查詢模式也決定了需要 SQLite 層還是 PostgreSQL 層（見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇&lt;/a>）。&lt;/p>
&lt;h2 id="debug-查詢">Debug 查詢&lt;/h2>
&lt;p>Debug 查詢回答「問題出在哪」。觸發時機是使用者回報問題或 error alert 觸發後，開發者需要還原問題的 context。&lt;/p>
&lt;h3 id="查詢場景">查詢場景&lt;/h3>
&lt;h4 id="剛才使用者回報的問題">剛才使用者回報的問題&lt;/h4>
&lt;p>查詢模式：用 session_id 過濾，拉出該 session 的全部事件，按時間排序。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">-- SQLite
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">type&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">data&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">events&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">session_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;abc-123&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>需要的事件欄位：session_id（關聯同次使用的事件）、ts（排序）、error 的 stack trace 和 step（定位失敗點）。&lt;/p>
&lt;h4 id="這個-error-多常發生">這個 error 多常發生&lt;/h4>
&lt;p>查詢模式：按 error name 分群計數，看時間趨勢。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">-- SQLite
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">as&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">strftime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;%Y-%m-%d&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">as&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">day&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">events&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">type&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;error&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">datetime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;now&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;-7 days&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">day&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">day&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DESC&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>需要的事件欄位：type=&amp;lsquo;error&amp;rsquo;、name（分群鍵）、ts（時間分桶）。&lt;/p>
&lt;h3 id="需要的事件">需要的事件&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>事件類型&lt;/th>
 &lt;th>必要欄位&lt;/th>
 &lt;th>用途&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>error&lt;/td>
 &lt;td>stack_trace, step, session_id&lt;/td>
 &lt;td>定位失敗點 + 關聯 session&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>event&lt;/td>
 &lt;td>name, session_id&lt;/td>
 &lt;td>還原使用者操作路徑&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>lifecycle&lt;/td>
 &lt;td>name, session_id&lt;/td>
 &lt;td>還原系統狀態轉換&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="alerting-查詢">Alerting 查詢&lt;/h2>
&lt;p>Alerting 查詢回答「需要注意嗎」。分兩種機制：rule engine 的即時評估（事件到達時逐筆比對規則）和事後查詢的趨勢分析。&lt;/p>
&lt;h3 id="查詢場景-1">查詢場景&lt;/h3>
&lt;h4 id="error-數量突然上升">Error 數量突然上升&lt;/h4>
&lt;p>查詢模式：最近 1 小時的 error 計數 vs 前一天同時段，偏差超過閾值則告警。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">-- SQLite
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">as&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">recent_count&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">events&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">type&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;error&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">datetime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;now&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;-1 hour&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Rule engine 的即時版：每收到一筆 error 事件，遞增計數器，計數器超過閾值觸發動作。&lt;/p>
&lt;h4 id="特定-error-首次出現">特定 error 首次出現&lt;/h4>
&lt;p>查詢模式：收到 error 時查是否有歷史記錄。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">-- SQLite
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">events&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">type&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;error&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">?&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">?&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>結果為 0 代表首次出現 — 觸發「新 error 類型」告警。Sentry 的核心功能之一就是這個查詢。&lt;/p>
&lt;h3 id="rule-engine-vs-事後查詢">Rule engine vs 事後查詢&lt;/h3>
&lt;p>Rule engine 逐筆評估，延遲在毫秒級，適合「error 出現就通知」。事後查詢用 SQL 聚合，延遲在秒到分鐘級，適合「過去一小時的 error 趨勢」。兩者互補 — rule engine 做即時告警、SQL 查詢做事後分析。&lt;/p></description><content:encoded><![CDATA[<p>事件的價值在於被查詢消費。設計事件時反過來想：查詢需要什麼欄位 → 事件需要帶什麼 data → 感測器需要在什麼時機觸發。從消費端反推設計，避免「收了一堆事件但查不到想要的答案」。</p>
<p>五種查詢場景各自需要不同的事件類型、欄位和查詢模式。每種場景的查詢模式也決定了需要 SQLite 層還是 PostgreSQL 層（見 <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a>）。</p>
<h2 id="debug-查詢">Debug 查詢</h2>
<p>Debug 查詢回答「問題出在哪」。觸發時機是使用者回報問題或 error alert 觸發後，開發者需要還原問題的 context。</p>
<h3 id="查詢場景">查詢場景</h3>
<h4 id="剛才使用者回報的問題">剛才使用者回報的問題</h4>
<p>查詢模式：用 session_id 過濾，拉出該 session 的全部事件，按時間排序。</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">-- SQLite
</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="k">type</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">ts</span><span class="p">,</span><span class="w"> </span><span class="k">data</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">session_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;abc-123&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</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">ts</span><span class="p">;</span></span></span></code></pre></div><p>需要的事件欄位：session_id（關聯同次使用的事件）、ts（排序）、error 的 stack trace 和 step（定位失敗點）。</p>
<h4 id="這個-error-多常發生">這個 error 多常發生</h4>
<p>查詢模式：按 error name 分群計數，看時間趨勢。</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">-- SQLite
</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">as</span><span class="w"> </span><span class="k">count</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">strftime</span><span class="p">(</span><span class="s1">&#39;%Y-%m-%d&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">ts</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="k">day</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><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></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-7 days&#39;</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="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 class="k">day</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">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">day</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="w"> </span><span class="k">DESC</span><span class="p">;</span></span></span></code></pre></div><p>需要的事件欄位：type=&lsquo;error&rsquo;、name（分群鍵）、ts（時間分桶）。</p>
<h3 id="需要的事件">需要的事件</h3>
<table>
  <thead>
      <tr>
          <th>事件類型</th>
          <th>必要欄位</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>error</td>
          <td>stack_trace, step, session_id</td>
          <td>定位失敗點 + 關聯 session</td>
      </tr>
      <tr>
          <td>event</td>
          <td>name, session_id</td>
          <td>還原使用者操作路徑</td>
      </tr>
      <tr>
          <td>lifecycle</td>
          <td>name, session_id</td>
          <td>還原系統狀態轉換</td>
      </tr>
  </tbody>
</table>
<h2 id="alerting-查詢">Alerting 查詢</h2>
<p>Alerting 查詢回答「需要注意嗎」。分兩種機制：rule engine 的即時評估（事件到達時逐筆比對規則）和事後查詢的趨勢分析。</p>
<h3 id="查詢場景-1">查詢場景</h3>
<h4 id="error-數量突然上升">Error 數量突然上升</h4>
<p>查詢模式：最近 1 小時的 error 計數 vs 前一天同時段，偏差超過閾值則告警。</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">-- SQLite
</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="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">recent_count</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><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></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-1 hour&#39;</span><span class="p">);</span></span></span></code></pre></div><p>Rule engine 的即時版：每收到一筆 error 事件，遞增計數器，計數器超過閾值觸發動作。</p>
<h4 id="特定-error-首次出現">特定 error 首次出現</h4>
<p>查詢模式：收到 error 時查是否有歷史記錄。</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">-- SQLite
</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="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></span><span class="line"><span class="ln">3</span><span class="cl"><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">AND</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">?</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="o">?</span><span class="p">;</span></span></span></code></pre></div><p>結果為 0 代表首次出現 — 觸發「新 error 類型」告警。Sentry 的核心功能之一就是這個查詢。</p>
<h3 id="rule-engine-vs-事後查詢">Rule engine vs 事後查詢</h3>
<p>Rule engine 逐筆評估，延遲在毫秒級，適合「error 出現就通知」。事後查詢用 SQL 聚合，延遲在秒到分鐘級，適合「過去一小時的 error 趨勢」。兩者互補 — rule engine 做即時告警、SQL 查詢做事後分析。</p>
<h3 id="需要的事件-1">需要的事件</h3>
<table>
  <thead>
      <tr>
          <th>事件類型</th>
          <th>必要欄位</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>error</td>
          <td>name, ts</td>
          <td>計數 + 時間趨勢</td>
      </tr>
      <tr>
          <td>error</td>
          <td>source.version</td>
          <td>按版本分群看是否新版本引入</td>
      </tr>
  </tbody>
</table>
<h2 id="產品決策查詢">產品決策查詢</h2>
<p>產品決策查詢回答「使用者怎麼用產品」。從簡單的功能使用率到複雜的 funnel 分析。</p>
<h3 id="查詢場景-2">查詢場景</h3>
<h4 id="新功能有多少人用">新功能有多少人用</h4>
<p>查詢模式：按 event name 計數。SQLite 層即可。</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">-- SQLite
</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">as</span><span class="w"> </span><span class="k">count</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="k">COUNT</span><span class="p">(</span><span class="k">DISTINCT</span><span class="w"> </span><span class="n">session_id</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">unique_sessions</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><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;event&#39;</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">AND</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;new_feature.%&#39;</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">AND</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-7 days&#39;</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="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></span></code></pre></div><h4 id="註冊流程在哪流失">註冊流程在哪流失</h4>
<p>查詢模式：session 級 funnel JOIN。需要 PostgreSQL 層。</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">-- PostgreSQL
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">WITH</span><span class="w"> </span><span class="n">session_steps</span><span class="w"> </span><span class="k">AS</span><span class="w"> </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="k">SELECT</span><span class="w"> </span><span class="n">session_id</span><span class="p">,</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"> 4</span><span class="cl"><span class="w">         </span><span class="n">ROW_NUMBER</span><span class="p">()</span><span class="w"> </span><span class="n">OVER</span><span class="w"> </span><span class="p">(</span><span class="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">session_id</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="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">step_order</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="k">FROM</span><span class="w"> </span><span class="n">events</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">WHERE</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;signup.start&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;signup.email&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;signup.verify&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;signup.complete&#39;</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="k">AND</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">NOW</span><span class="p">()</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="nb">INTERVAL</span><span class="w"> </span><span class="s1">&#39;30 days&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></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="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="k">DISTINCT</span><span class="w"> </span><span class="n">session_id</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">sessions</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">session_steps</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><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="w">
</span></span></span><span class="line"><span class="ln">12</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="k">MIN</span><span class="p">(</span><span class="n">step_order</span><span class="p">);</span></span></span></code></pre></div><p>完整的 funnel 分析方法論見 <a href="/blog/monitoring/08-business-analytics/self-hosted-funnel/" data-link-title="從 collector 資料做基礎 funnel 分析" data-link-desc="SQLite 層能做什麼程度的 funnel、PostgreSQL 層提供什麼進階能力、JSONL 匯出後的臨時分析">從 collector 資料做基礎 funnel 分析</a>。</p>
<h3 id="需要的事件-2">需要的事件</h3>
<table>
  <thead>
      <tr>
          <th>事件類型</th>
          <th>必要欄位</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>event</td>
          <td>name, session_id, ts</td>
          <td>漏斗步驟計數和排序</td>
      </tr>
      <tr>
          <td>lifecycle</td>
          <td>session.start, ts</td>
          <td>session 邊界定義</td>
      </tr>
  </tbody>
</table>
<h2 id="安全審計查詢">安全審計查詢</h2>
<p>安全審計查詢回答「有沒有非預期的存取」。重點是偵測異常模式而非單筆事件。</p>
<h3 id="查詢場景-3">查詢場景</h3>
<h4 id="有沒有異常登入">有沒有異常登入</h4>
<p>查詢模式：auth 失敗事件按 session 分群計數，短時間內大量失敗 = 暴力破解嘗試。</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">-- SQLite
</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">session_id</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">as</span><span class="w"> </span><span class="n">fail_count</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="k">MIN</span><span class="p">(</span><span class="n">ts</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">first_attempt</span><span class="p">,</span><span class="w"> </span><span class="k">MAX</span><span class="p">(</span><span class="n">ts</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">last_attempt</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><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">AND</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;auth.login.failed&#39;</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">AND</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-1 hour&#39;</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="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">session_id</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">HAVING</span><span class="w"> </span><span class="n">fail_count</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">5</span><span class="p">;</span></span></span></code></pre></div><h4 id="誰存取了什麼敏感資料">誰存取了什麼敏感資料</h4>
<p>查詢模式：敏感操作的 audit trail — 按時間列出所有敏感操作事件。</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">-- SQLite
</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">ts</span><span class="p">,</span><span class="w"> </span><span class="n">session_id</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="k">data</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><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;event&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;data.export&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;admin.user_lookup&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;config.secret_read&#39;</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">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="p">;</span></span></span></code></pre></div><h3 id="需要的事件-3">需要的事件</h3>
<table>
  <thead>
      <tr>
          <th>事件類型</th>
          <th>必要欄位</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>error</td>
          <td>name=&lsquo;auth.*.failed&rsquo;, session_id</td>
          <td>偵測暴力破解</td>
      </tr>
      <tr>
          <td>event</td>
          <td>敏感操作的 name, session_id</td>
          <td>audit trail</td>
      </tr>
      <tr>
          <td>event</td>
          <td>data 中的操作目標（哪筆資料）</td>
          <td>存取範圍追溯</td>
      </tr>
  </tbody>
</table>
<p>安全事件的取樣率必須是 1.0（全收）— 取樣會讓攻擊嘗試在統計上隱形。見 <a href="/blog/monitoring/03-sdk-design/sensor-lifecycle-management/" data-link-title="感測器生命週期管理" data-link-desc="產品生命週期的五個階段各啟用什麼感測器 — feature flag 整合、取樣率動態調整、感測器開關的可觀察性">感測器生命週期管理</a> 的取樣率設計段。</p>
<h2 id="效能查詢">效能查詢</h2>
<p>效能查詢回答「系統有多快」和「哪裡變慢了」。</p>
<h3 id="查詢場景-4">查詢場景</h3>
<h4 id="p95-回應時間趨勢">P95 回應時間趨勢</h4>
<p>查詢模式：時間分桶 + percentile 聚合。需要 PostgreSQL 層。</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">-- PostgreSQL
</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">date_trunc</span><span class="p">(</span><span class="s1">&#39;hour&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">ts</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">hour</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">percentile_cont</span><span class="p">(</span><span class="mi">0</span><span class="p">.</span><span class="mi">95</span><span class="p">)</span><span class="w"> </span><span class="n">WITHIN</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="p">(</span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="p">(</span><span class="k">data</span><span class="o">-&gt;&gt;</span><span class="s1">&#39;duration_ms&#39;</span><span class="p">)::</span><span class="nb">int</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">p95</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><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;metric&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;api.response.duration&#39;</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">AND</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">NOW</span><span class="p">()</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="nb">INTERVAL</span><span class="w"> </span><span class="s1">&#39;7 days&#39;</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">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">hour</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">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">hour</span><span class="p">;</span></span></span></code></pre></div><p>SQLite 沒有內建 percentile 函數。SQLite 層的替代方案是排序後取第 95% 位置的值，但在大資料量時效能差。</p>
<h4 id="哪個版本變慢了">哪個版本變慢了</h4>
<p>查詢模式：按 source.version 分群比較效能。</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">-- SQLite / PostgreSQL
</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">source_version</span><span class="p">,</span><span class="w"> </span><span class="k">AVG</span><span class="p">((</span><span class="k">data</span><span class="o">-&gt;&gt;</span><span class="s1">&#39;duration_ms&#39;</span><span class="p">)::</span><span class="nb">int</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">avg_ms</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="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">sample_count</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><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;metric&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;api.response.duration&#39;</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">AND</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-7 days&#39;</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="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">source_version</span><span class="p">;</span></span></span></code></pre></div><h3 id="需要的事件-4">需要的事件</h3>
<table>
  <thead>
      <tr>
          <th>事件類型</th>
          <th>必要欄位</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>metric</td>
          <td>name, data.duration_ms, ts</td>
          <td>延遲趨勢</td>
      </tr>
      <tr>
          <td>metric</td>
          <td>source.version</td>
          <td>按版本比較</td>
      </tr>
      <tr>
          <td>metric</td>
          <td>data.memory_mb, data.cpu_percent</td>
          <td>資源使用趨勢</td>
      </tr>
  </tbody>
</table>
<h2 id="查詢--事件反推表">查詢 → 事件反推表</h2>
<p>設計事件時用這張表反向確認：每種查詢場景需要什麼事件、什麼欄位、什麼 storage 層級。</p>
<table>
  <thead>
      <tr>
          <th>查詢場景</th>
          <th>事件類型</th>
          <th>必要欄位</th>
          <th>Storage 層級</th>
          <th>保留需求</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Session 回放</td>
          <td>全部</td>
          <td>session_id, ts</td>
          <td>SQLite</td>
          <td>原始 7d</td>
      </tr>
      <tr>
          <td>Error 計數趨勢</td>
          <td>error</td>
          <td>name, ts</td>
          <td>SQLite</td>
          <td>小時聚合 90d</td>
      </tr>
      <tr>
          <td>功能使用率</td>
          <td>event</td>
          <td>name</td>
          <td>SQLite</td>
          <td>天聚合 365d</td>
      </tr>
      <tr>
          <td>Funnel 分析</td>
          <td>event</td>
          <td>name, session_id, ts</td>
          <td>PostgreSQL</td>
          <td>原始 30d</td>
      </tr>
      <tr>
          <td>暴力破解偵測</td>
          <td>error</td>
          <td>auth name, session_id</td>
          <td>SQLite</td>
          <td>原始 30d</td>
      </tr>
      <tr>
          <td>Audit trail</td>
          <td>event</td>
          <td>敏感操作 name, session_id</td>
          <td>SQLite</td>
          <td>原始 365d</td>
      </tr>
      <tr>
          <td>P95 趨勢</td>
          <td>metric</td>
          <td>duration_ms, ts</td>
          <td>PostgreSQL</td>
          <td>小時聚合 90d</td>
      </tr>
      <tr>
          <td>版本比較</td>
          <td>metric</td>
          <td>duration_ms, version</td>
          <td>SQLite</td>
          <td>天聚合 365d</td>
      </tr>
  </tbody>
</table>
<p>這張表和 <a href="/blog/monitoring/01-mental-model/event-enumeration-method/" data-link-title="事件枚舉與補齊檢查" data-link-desc="從操作盤點系統性地推導出完整的事件清單 — 四類補齊檢查確保沒有遺漏、粒度判準確保每個事件只記一個事實">事件枚舉與補齊檢查</a> 的事件表互補 — 事件枚舉從操作端正向推導「要收什麼」，本表從查詢端反向確認「收的夠不夠」。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>從操作端正向推導事件 → <a href="/blog/monitoring/01-mental-model/event-enumeration-method/" data-link-title="事件枚舉與補齊檢查" data-link-desc="從操作盤點系統性地推導出完整的事件清單 — 四類補齊檢查確保沒有遺漏、粒度判準確保每個事件只記一個事實">事件枚舉與補齊檢查</a></li>
<li>動機和事件的對應關係 → <a href="/blog/monitoring/01-mental-model/motivation-to-event-mapping/" data-link-title="動機驅動的事件設計" data-link-desc="Debug / 商業 / 資安 / 效能四個動機各自需要什麼事件 — 從「為什麼收」反推「收什麼」和「什麼階段啟用」">動機驅動的事件設計</a></li>
<li>SQLite vs PostgreSQL 的查詢能力分界 → <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a></li>
<li>Rule engine 的即時評估 → <a href="/blog/monitoring/04-collector/rule-engine/" data-link-title="Rule engine 設計" data-link-desc="條件 → 動作 → 模板的三段式規則結構 — 讓 collector 從被動儲存變成主動回應">Rule engine 設計</a></li>
</ul>
]]></content:encoded></item><item><title>感測器生命週期管理</title><link>https://tarrragon.github.io/blog/monitoring/03-sdk-design/sensor-lifecycle-management/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/03-sdk-design/sensor-lifecycle-management/</guid><description>&lt;p>感測器的啟用組合隨產品階段變化。早期開發只需要 error 和 lifecycle 幫助 debug，production 上線後需要商業事件和效能量測，A/B 測試期間需要實驗專用感測器。把所有感測器一次全開會浪費頻寬和儲存、產生大量低價值事件；全程只開 error 則在需要行為分析時發現沒有資料。感測器的啟停是設計決策，由 SDK config、collector 下發和 feature flag 三層機制控制。&lt;/p>
&lt;h2 id="五個階段">五個階段&lt;/h2>
&lt;h3 id="早期開發">早期開發&lt;/h3>
&lt;p>開發期的首要需求是 debug — 程式碼寫完跑起來、出問題時能定位。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>感測器類型&lt;/th>
 &lt;th>啟用&lt;/th>
 &lt;th>理由&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>error&lt;/td>
 &lt;td>全開&lt;/td>
 &lt;td>每個例外都要看到&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>lifecycle&lt;/td>
 &lt;td>全開&lt;/td>
 &lt;td>app 啟動、連線、狀態轉換的步驟紀錄&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>event&lt;/td>
 &lt;td>按需&lt;/td>
 &lt;td>正在開發的功能手動加埋點，其他關閉&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>metric&lt;/td>
 &lt;td>關閉&lt;/td>
 &lt;td>效能量測在功能穩定前沒有意義&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>開發期的取樣率全部設 1.0（全收）— 事件量極低（開發者自己操作），不需要取樣。&lt;/p>
&lt;h3 id="功能測試">功能測試&lt;/h3>
&lt;p>針對被測功能開啟完整感測器，驗證功能的行為事件和效能指標是否正確觸發。&lt;/p>
&lt;p>被測功能的 event 和 metric 全開。其他功能維持開發期設定。測試期間的感測器設定通常由測試 config 檔覆寫 SDK 預設值。&lt;/p>
&lt;h3 id="production-上線">Production 上線&lt;/h3>
&lt;p>上線後的感測器組合平衡覆蓋率和成本：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>感測器類型&lt;/th>
 &lt;th>策略&lt;/th>
 &lt;th>理由&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>error&lt;/td>
 &lt;td>全收&lt;/td>
 &lt;td>每個 production error 都有 debug 價值&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>lifecycle&lt;/td>
 &lt;td>全收&lt;/td>
 &lt;td>session 分析和環境資訊需要完整紀錄&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>event（核心操作）&lt;/td>
 &lt;td>全收&lt;/td>
 &lt;td>漏斗關鍵步驟、轉換事件不能漏&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>event（高頻 UI）&lt;/td>
 &lt;td>取樣&lt;/td>
 &lt;td>scroll、mousemove、hover 等高頻操作只取部分&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>metric&lt;/td>
 &lt;td>取樣&lt;/td>
 &lt;td>效能指標按時間取樣（每 30 秒一次而非每 frame）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>安全事件&lt;/td>
 &lt;td>全收&lt;/td>
 &lt;td>auth 失敗、權限越界、敏感操作不取樣&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="ab-測試">A/B 測試&lt;/h3>
&lt;p>實驗感測器只對 treatment group 啟用。Control group 不觸發實驗事件，避免污染對照組資料。&lt;/p>
&lt;p>實驗專用事件（&lt;code>experiment.pricing_test.assigned&lt;/code>、&lt;code>experiment.pricing_test.converted&lt;/code>）由 feature flag 控制 — flag 開啟時 SDK 才送這些事件。實驗結束後 flag 關閉，感測器自動停止。&lt;/p>
&lt;p>實驗事件的保留期和實驗週期綁定，實驗結束 + 分析完成後可以 purge。&lt;/p>
&lt;h3 id="功能下線">功能下線&lt;/h3>
&lt;p>功能移除時，對應的感測器 config 一起移除。Collector 端 purge 該功能的歷史事件（或降級到聚合摘要）。&lt;/p>
&lt;p>移除 checklist：SDK config 移除事件名稱 → SDK 版本部署 → 確認 collector 不再收到該事件 → purge 歷史資料（可選）。&lt;/p>
&lt;h2 id="控制機制">控制機制&lt;/h2>
&lt;p>三層控制機制各自適合不同的變更頻率：&lt;/p>
&lt;h3 id="sdk-init-config靜態">SDK init config（靜態）&lt;/h3>
&lt;p>隨 app 版本部署的本地設定檔。變更需要發新版本。適合穩定的感測器組合。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="nt">sensors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">error&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled: true, sampling&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">lifecycle&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled: true, sampling&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">event&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">funnel.*&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled: true, sampling&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">click.*&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled: true, sampling&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.1&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metric&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">duration&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled: true, sampling&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.5&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">experiment&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pricing_test&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w"> &lt;/span>}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="collector-端下發動態">Collector 端下發（動態）&lt;/h3>
&lt;p>SDK 啟動時從 collector 的 &lt;code>/config&lt;/code> endpoint 拉取當前的感測器設定。Collector 端修改設定後，下一次 SDK 重啟或定期 refresh（每 5 分鐘）時生效。適合需要動態調整但不值得接 feature flag 服務的場景。&lt;/p></description><content:encoded><![CDATA[<p>感測器的啟用組合隨產品階段變化。早期開發只需要 error 和 lifecycle 幫助 debug，production 上線後需要商業事件和效能量測，A/B 測試期間需要實驗專用感測器。把所有感測器一次全開會浪費頻寬和儲存、產生大量低價值事件；全程只開 error 則在需要行為分析時發現沒有資料。感測器的啟停是設計決策，由 SDK config、collector 下發和 feature flag 三層機制控制。</p>
<h2 id="五個階段">五個階段</h2>
<h3 id="早期開發">早期開發</h3>
<p>開發期的首要需求是 debug — 程式碼寫完跑起來、出問題時能定位。</p>
<table>
  <thead>
      <tr>
          <th>感測器類型</th>
          <th>啟用</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>error</td>
          <td>全開</td>
          <td>每個例外都要看到</td>
      </tr>
      <tr>
          <td>lifecycle</td>
          <td>全開</td>
          <td>app 啟動、連線、狀態轉換的步驟紀錄</td>
      </tr>
      <tr>
          <td>event</td>
          <td>按需</td>
          <td>正在開發的功能手動加埋點，其他關閉</td>
      </tr>
      <tr>
          <td>metric</td>
          <td>關閉</td>
          <td>效能量測在功能穩定前沒有意義</td>
      </tr>
  </tbody>
</table>
<p>開發期的取樣率全部設 1.0（全收）— 事件量極低（開發者自己操作），不需要取樣。</p>
<h3 id="功能測試">功能測試</h3>
<p>針對被測功能開啟完整感測器，驗證功能的行為事件和效能指標是否正確觸發。</p>
<p>被測功能的 event 和 metric 全開。其他功能維持開發期設定。測試期間的感測器設定通常由測試 config 檔覆寫 SDK 預設值。</p>
<h3 id="production-上線">Production 上線</h3>
<p>上線後的感測器組合平衡覆蓋率和成本：</p>
<table>
  <thead>
      <tr>
          <th>感測器類型</th>
          <th>策略</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>error</td>
          <td>全收</td>
          <td>每個 production error 都有 debug 價值</td>
      </tr>
      <tr>
          <td>lifecycle</td>
          <td>全收</td>
          <td>session 分析和環境資訊需要完整紀錄</td>
      </tr>
      <tr>
          <td>event（核心操作）</td>
          <td>全收</td>
          <td>漏斗關鍵步驟、轉換事件不能漏</td>
      </tr>
      <tr>
          <td>event（高頻 UI）</td>
          <td>取樣</td>
          <td>scroll、mousemove、hover 等高頻操作只取部分</td>
      </tr>
      <tr>
          <td>metric</td>
          <td>取樣</td>
          <td>效能指標按時間取樣（每 30 秒一次而非每 frame）</td>
      </tr>
      <tr>
          <td>安全事件</td>
          <td>全收</td>
          <td>auth 失敗、權限越界、敏感操作不取樣</td>
      </tr>
  </tbody>
</table>
<h3 id="ab-測試">A/B 測試</h3>
<p>實驗感測器只對 treatment group 啟用。Control group 不觸發實驗事件，避免污染對照組資料。</p>
<p>實驗專用事件（<code>experiment.pricing_test.assigned</code>、<code>experiment.pricing_test.converted</code>）由 feature flag 控制 — flag 開啟時 SDK 才送這些事件。實驗結束後 flag 關閉，感測器自動停止。</p>
<p>實驗事件的保留期和實驗週期綁定，實驗結束 + 分析完成後可以 purge。</p>
<h3 id="功能下線">功能下線</h3>
<p>功能移除時，對應的感測器 config 一起移除。Collector 端 purge 該功能的歷史事件（或降級到聚合摘要）。</p>
<p>移除 checklist：SDK config 移除事件名稱 → SDK 版本部署 → 確認 collector 不再收到該事件 → purge 歷史資料（可選）。</p>
<h2 id="控制機制">控制機制</h2>
<p>三層控制機制各自適合不同的變更頻率：</p>
<h3 id="sdk-init-config靜態">SDK init config（靜態）</h3>
<p>隨 app 版本部署的本地設定檔。變更需要發新版本。適合穩定的感測器組合。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">sensors</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="nt">error</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">enabled: true, sampling</span><span class="p">:</span><span class="w"> </span><span class="m">1.0</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">lifecycle</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">enabled: true, sampling</span><span class="p">:</span><span class="w"> </span><span class="m">1.0</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="nt">event</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="nt">funnel.*</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">enabled: true, sampling</span><span class="p">:</span><span class="w"> </span><span class="m">1.0</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">click.*</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">enabled: true, sampling</span><span class="p">:</span><span class="w"> </span><span class="m">0.1</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">  </span><span class="nt">metric</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="nt">duration</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">enabled: true, sampling</span><span class="p">:</span><span class="w"> </span><span class="m">0.5</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span><span class="nt">experiment</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="nt">pricing_test</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">enabled</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w"> </span>}</span></span></code></pre></div><h3 id="collector-端下發動態">Collector 端下發（動態）</h3>
<p>SDK 啟動時從 collector 的 <code>/config</code> endpoint 拉取當前的感測器設定。Collector 端修改設定後，下一次 SDK 重啟或定期 refresh（每 5 分鐘）時生效。適合需要動態調整但不值得接 feature flag 服務的場景。</p>
<p>MVP 階段跳過 collector 下發，只用 SDK 本地 config。下發 API 的定義和實作標為第二階段 — 感測器的開關在 SDK 本地 config 已經能完全控制。</p>
<h3 id="feature-flag-服務整合">Feature flag 服務整合</h3>
<p>SDK 在送出事件前查詢 feature flag 判斷感測器是否啟用。適合 A/B 測試 — flag 可以按使用者 / 百分比 / 條件分群啟用。</p>
<h3 id="優先順序">優先順序</h3>
<p>三層控制的覆蓋優先順序：</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">Feature flag &gt; Collector 下發 &gt; SDK 本地 config</span></span></code></pre></div><p>SDK 本地 config 是 baseline。Collector 下發覆蓋 baseline 的特定欄位。Feature flag 覆蓋一切 — 即使本地 config 和 collector 都說啟用，flag 說關閉就關閉。</p>
<h2 id="取樣率設計">取樣率設計</h2>
<p>取樣率決定「多少比例的事件會被實際送出」。取樣在 SDK 端執行 — 不送的事件不佔頻寬和儲存。</p>
<h3 id="全收sampling-10">全收（sampling: 1.0）</h3>
<p>每筆事件都送。適用於：</p>
<ul>
<li><strong>error</strong>：每個 production error 都有 debug 價值，漏掉的 error 可能是最嚴重的那個</li>
<li><strong>安全事件</strong>：auth 失敗、權限越界的取樣可能讓攻擊嘗試隱形</li>
<li><strong>漏斗關鍵步驟</strong>：funnel 分析的轉換率計算需要精確的步驟計數</li>
</ul>
<h3 id="百分比取樣001-05">百分比取樣（0.01-0.5）</h3>
<p>只送一定比例的事件。適用於高頻且個別事件價值低的場景：</p>
<ul>
<li>scroll / mousemove / hover：每秒觸發數十次，全收會產生大量事件。取樣 1-10% 足以分析使用者行為模式</li>
<li>frame rate 量測：每幀一筆 metric 太多，每秒或每 30 秒取一筆足夠</li>
</ul>
<p>取樣的實作用 SDK 端的隨機數 — <code>if random() &lt; sampling_rate then send(event)</code> — 不需要 server 端參與。</p>
<h3 id="條件取樣retrospective-full-capture">條件取樣（retrospective full capture）</h3>
<p>正常情況取樣，但發生 error 時回溯收集該 session 的全部事件。實作方式是 SDK 在記憶體中保留最近 N 筆事件的環形 buffer，觸發 error 時把 buffer 中的事件一併送出。</p>
<p>條件取樣讓「error session 的上下文完整」和「正常 session 不過度收集」兩個目標共存。</p>
<h2 id="感測器開關的可觀察性">感測器開關的可觀察性</h2>
<p>感測器本身的狀態變化需要被觀察 — 如果感測器靜默失效（config 錯誤導致某類事件停送），開發者可能很久後才發現「怎麼最近沒有 funnel 資料」。</p>
<h3 id="啟動時-log-感測器清單">啟動時 log 感測器清單</h3>
<p>SDK 初始化完成時 log 當前啟用的感測器清單和取樣率。開發者在 debug console 就能看到「哪些感測器在跑」。</p>
<h3 id="config-變更事件">Config 變更事件</h3>
<p>感測器 config 變更時（collector 下發新 config、或 feature flag 變化），SDK 送一個 lifecycle 事件：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;lifecycle&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;sensor.config.changed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;data&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nt">&#34;source&#34;</span><span class="p">:</span> <span class="s2">&#34;collector_push&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nt">&#34;changed&#34;</span><span class="p">:</span> <span class="p">{</span><span class="nt">&#34;click.*&#34;</span><span class="p">:</span> <span class="p">{</span><span class="nt">&#34;sampling&#34;</span><span class="p">:</span> <span class="s2">&#34;0.1 → 0.05&#34;</span><span class="p">}},</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nt">&#34;active_sensors&#34;</span><span class="p">:</span> <span class="mi">12</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這筆事件讓開發者在查詢時能看到「某個時間點感測器 config 改變了」，和事件量的變化做交叉比對。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>感測器偵測哪些行為 → <a href="/blog/monitoring/03-sdk-design/frontend-sensor-design/" data-link-title="前端感測器設計" data-link-desc="什麼行為值得埋感測器、每類感測器的實作方式、取樣策略和效能影響 — 和 auto-intercept 的被動攔截互補">前端感測器設計</a></li>
<li>SDK 的公開 API → <a href="/blog/monitoring/03-sdk-design/public-api/" data-link-title="SDK 公開 API 設計" data-link-desc="init / event / error / metric / flush / close 六個方法構成 SDK 的完整生命週期 — 跨平台共用相同 API 介面">SDK 公開 API 設計</a></li>
<li>四類事件的定義 → <a href="/blog/monitoring/01-mental-model/four-event-types/" data-link-title="四類事件的完整定義" data-link-desc="Event / Error / Metric / Lifecycle 四類事件各自的語意、觸發時機和典型用途 — 分類是監控體系的統一語言">四類事件的完整定義</a></li>
<li>事件枚舉方法 → <a href="/blog/monitoring/01-mental-model/event-enumeration-method/" data-link-title="事件枚舉與補齊檢查" data-link-desc="從操作盤點系統性地推導出完整的事件清單 — 四類補齊檢查確保沒有遺漏、粒度判準確保每個事件只記一個事實">事件枚舉與補齊檢查</a></li>
</ul>
]]></content:encoded></item><item><title>RFM 分群</title><link>https://tarrragon.github.io/blog/monitoring/08-business-analytics/rfm-segmentation/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/08-business-analytics/rfm-segmentation/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/rfm/" data-link-title="RFM" data-link-desc="說明用 Recency / Frequency / Monetary 三個維度把使用者分成可操作群組的分群方法">RFM&lt;/a> 分群用三個維度衡量使用者的價值：Recency（最近一次互動是多久前）、Frequency（互動的頻率）、Monetary（互動的金額或價值）。三個維度各自獨立評分，組合成使用者的 RFM profile，驅動差異化的營運策略。&lt;/p>
&lt;h2 id="三個維度">三個維度&lt;/h2>
&lt;h3 id="recency最近一次互動的時間距離">Recency：最近一次互動的時間距離&lt;/h3>
&lt;p>計算使用者最後一次有意義的互動到現在的天數。「有意義的互動」取決於業務定義 — 電商是最後一次購買，SaaS 是最後一次登入，媒體是最後一次內容消費。&lt;/p>
&lt;p>Recency 的價值在於「最近互動的使用者比很久沒來的使用者更可能再次互動」。Recency 高（最近才來）的使用者是活躍群體，Recency 低（很久沒來）的使用者是流失風險群體。&lt;/p>
&lt;h3 id="frequency互動的頻率">Frequency：互動的頻率&lt;/h3>
&lt;p>計算使用者在特定時間窗口內的互動次數。時間窗口取決於業務節奏 — 日用品電商看近 90 天的購買次數，SaaS 看近 30 天的登入次數。&lt;/p>
&lt;p>Frequency 區分「偶爾來的使用者」和「常客」。高頻使用者是產品的核心用戶群，他們的行為和需求代表產品的核心價值。&lt;/p>
&lt;h3 id="monetary互動的價值">Monetary：互動的價值&lt;/h3>
&lt;p>計算使用者在特定時間窗口內貢獻的總金額。適用於有直接收入的業務（電商、訂閱服務）。&lt;/p>
&lt;p>沒有直接收入的產品可以用替代指標：內容平台用消費的內容數量，社群平台用產生的內容數量，工具類產品用使用的功能數量。替代指標的選擇依據是「哪個行為最能代表使用者的投入程度」。&lt;/p>
&lt;h2 id="rfm-分數計算">RFM 分數計算&lt;/h2>
&lt;p>每個維度獨立評分，通常用 1-5 分。評分方式有兩種：&lt;/p>
&lt;h3 id="等距分割">等距分割&lt;/h3>
&lt;p>把每個維度的值域等分成 5 段。Recency 0-6 天 = 5 分、7-13 天 = 4 分、依此類推。&lt;/p>
&lt;p>優點是簡單直覺；缺點是不考慮使用者分佈 — 如果大部分使用者的 Recency 在 0-6 天，5 分的群體佔大多數，分群的鑑別度低。&lt;/p>
&lt;h3 id="等量分割分位數">等量分割（分位數）&lt;/h3>
&lt;p>用分位數確保每個分數段的使用者數量大致相等。前 20% 的 Recency = 5 分、次 20% = 4 分。&lt;/p>
&lt;p>優點是每個分數段有足夠的使用者數量做分析；缺點是分數的業務意義不固定 — 5 分代表的天數取決於使用者分佈，不是固定的閾值。&lt;/p>
&lt;h2 id="rfm-群體定義">RFM 群體定義&lt;/h2>
&lt;p>三個維度各 5 分，組合出 125 種 RFM profile（5 × 5 × 5）。實務上不需要 125 種策略，通常歸納成 5-8 個有業務意義的群體：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>群體&lt;/th>
 &lt;th>RFM 特徵&lt;/th>
 &lt;th>描述&lt;/th>
 &lt;th>策略方向&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>冠軍客戶&lt;/td>
 &lt;td>R5 F5 M5&lt;/td>
 &lt;td>最近才來、經常來、消費高&lt;/td>
 &lt;td>維持關係、VIP 待遇&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>忠實客戶&lt;/td>
 &lt;td>R4-5 F4-5 M3-5&lt;/td>
 &lt;td>經常來、消費中到高&lt;/td>
 &lt;td>交叉銷售、推薦&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>潛力客戶&lt;/td>
 &lt;td>R4-5 F1-2 M1-2&lt;/td>
 &lt;td>最近才來、但頻率和消費低&lt;/td>
 &lt;td>引導更多互動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>沉睡客戶&lt;/td>
 &lt;td>R1-2 F3-5 M3-5&lt;/td>
 &lt;td>曾經活躍但很久沒來&lt;/td>
 &lt;td>挽回活動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>流失客戶&lt;/td>
 &lt;td>R1 F1 M1&lt;/td>
 &lt;td>很久沒來、頻率低、消費低&lt;/td>
 &lt;td>評估挽回成本效益&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="工程實作">工程實作&lt;/h2>
&lt;p>RFM 計算的輸入是使用者的行為事件。從 collector 的 JSONL 資料計算 RFM：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>擷取&lt;/strong>：篩選目標事件（購買、登入、使用功能），按 user_id 分群&lt;/li>
&lt;li>&lt;strong>計算 R&lt;/strong>：每個 user_id 的最新事件時間到現在的天數&lt;/li>
&lt;li>&lt;strong>計算 F&lt;/strong>：每個 user_id 在時間窗口內的事件數量&lt;/li>
&lt;li>&lt;strong>計算 M&lt;/strong>：每個 user_id 在時間窗口內的 monetary 屬性加總&lt;/li>
&lt;li>&lt;strong>評分&lt;/strong>：對 R/F/M 各自用分位數或等距分割評分&lt;/li>
&lt;li>&lt;strong>分群&lt;/strong>：根據 RFM 分數組合定義群體&lt;/li>
&lt;/ol>
&lt;p>這個計算可以用 SQL（如果資料在資料庫）或 Python pandas（如果資料在 JSONL 檔案）完成。定期重算（每天或每週），產出使用者群體標籤。&lt;/p>
&lt;p>RFM 分群需要的資料可以從自架 collector 提取 — &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/self-hosted-funnel/" data-link-title="從 collector 資料做基礎 funnel 分析" data-link-desc="SQLite 層能做什麼程度的 funnel、PostgreSQL 層提供什麼進階能力、JSONL 匯出後的臨時分析">從 collector 資料做基礎 funnel 分析&lt;/a>展示了 grep + jq 在自架環境中的分析能力和邊界。RFM 分出的群體還可以用 &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/cohort-analysis/" data-link-title="Cohort Analysis" data-link-desc="按共同特徵分群、比較不同群體的留存率和行為差異 — 從「平均值」到「誰在用、誰離開了」">Cohort analysis&lt;/a> 追蹤留存趨勢，兩種分析互補。分群和分析的前提是正確的&lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/behavior-event-design/" data-link-title="行為事件設計" data-link-desc="事件命名規範、屬性設計、funnel 定義 — 行為分析的品質取決於事件設計的品質">行為事件設計&lt;/a> — 事件的屬性決定了 R/F/M 能否被計算。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/monitoring/knowledge-cards/rfm/" data-link-title="RFM" data-link-desc="說明用 Recency / Frequency / Monetary 三個維度把使用者分成可操作群組的分群方法">RFM</a> 分群用三個維度衡量使用者的價值：Recency（最近一次互動是多久前）、Frequency（互動的頻率）、Monetary（互動的金額或價值）。三個維度各自獨立評分，組合成使用者的 RFM profile，驅動差異化的營運策略。</p>
<h2 id="三個維度">三個維度</h2>
<h3 id="recency最近一次互動的時間距離">Recency：最近一次互動的時間距離</h3>
<p>計算使用者最後一次有意義的互動到現在的天數。「有意義的互動」取決於業務定義 — 電商是最後一次購買，SaaS 是最後一次登入，媒體是最後一次內容消費。</p>
<p>Recency 的價值在於「最近互動的使用者比很久沒來的使用者更可能再次互動」。Recency 高（最近才來）的使用者是活躍群體，Recency 低（很久沒來）的使用者是流失風險群體。</p>
<h3 id="frequency互動的頻率">Frequency：互動的頻率</h3>
<p>計算使用者在特定時間窗口內的互動次數。時間窗口取決於業務節奏 — 日用品電商看近 90 天的購買次數，SaaS 看近 30 天的登入次數。</p>
<p>Frequency 區分「偶爾來的使用者」和「常客」。高頻使用者是產品的核心用戶群，他們的行為和需求代表產品的核心價值。</p>
<h3 id="monetary互動的價值">Monetary：互動的價值</h3>
<p>計算使用者在特定時間窗口內貢獻的總金額。適用於有直接收入的業務（電商、訂閱服務）。</p>
<p>沒有直接收入的產品可以用替代指標：內容平台用消費的內容數量，社群平台用產生的內容數量，工具類產品用使用的功能數量。替代指標的選擇依據是「哪個行為最能代表使用者的投入程度」。</p>
<h2 id="rfm-分數計算">RFM 分數計算</h2>
<p>每個維度獨立評分，通常用 1-5 分。評分方式有兩種：</p>
<h3 id="等距分割">等距分割</h3>
<p>把每個維度的值域等分成 5 段。Recency 0-6 天 = 5 分、7-13 天 = 4 分、依此類推。</p>
<p>優點是簡單直覺；缺點是不考慮使用者分佈 — 如果大部分使用者的 Recency 在 0-6 天，5 分的群體佔大多數，分群的鑑別度低。</p>
<h3 id="等量分割分位數">等量分割（分位數）</h3>
<p>用分位數確保每個分數段的使用者數量大致相等。前 20% 的 Recency = 5 分、次 20% = 4 分。</p>
<p>優點是每個分數段有足夠的使用者數量做分析；缺點是分數的業務意義不固定 — 5 分代表的天數取決於使用者分佈，不是固定的閾值。</p>
<h2 id="rfm-群體定義">RFM 群體定義</h2>
<p>三個維度各 5 分，組合出 125 種 RFM profile（5 × 5 × 5）。實務上不需要 125 種策略，通常歸納成 5-8 個有業務意義的群體：</p>
<table>
  <thead>
      <tr>
          <th>群體</th>
          <th>RFM 特徵</th>
          <th>描述</th>
          <th>策略方向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>冠軍客戶</td>
          <td>R5 F5 M5</td>
          <td>最近才來、經常來、消費高</td>
          <td>維持關係、VIP 待遇</td>
      </tr>
      <tr>
          <td>忠實客戶</td>
          <td>R4-5 F4-5 M3-5</td>
          <td>經常來、消費中到高</td>
          <td>交叉銷售、推薦</td>
      </tr>
      <tr>
          <td>潛力客戶</td>
          <td>R4-5 F1-2 M1-2</td>
          <td>最近才來、但頻率和消費低</td>
          <td>引導更多互動</td>
      </tr>
      <tr>
          <td>沉睡客戶</td>
          <td>R1-2 F3-5 M3-5</td>
          <td>曾經活躍但很久沒來</td>
          <td>挽回活動</td>
      </tr>
      <tr>
          <td>流失客戶</td>
          <td>R1 F1 M1</td>
          <td>很久沒來、頻率低、消費低</td>
          <td>評估挽回成本效益</td>
      </tr>
  </tbody>
</table>
<h2 id="工程實作">工程實作</h2>
<p>RFM 計算的輸入是使用者的行為事件。從 collector 的 JSONL 資料計算 RFM：</p>
<ol>
<li><strong>擷取</strong>：篩選目標事件（購買、登入、使用功能），按 user_id 分群</li>
<li><strong>計算 R</strong>：每個 user_id 的最新事件時間到現在的天數</li>
<li><strong>計算 F</strong>：每個 user_id 在時間窗口內的事件數量</li>
<li><strong>計算 M</strong>：每個 user_id 在時間窗口內的 monetary 屬性加總</li>
<li><strong>評分</strong>：對 R/F/M 各自用分位數或等距分割評分</li>
<li><strong>分群</strong>：根據 RFM 分數組合定義群體</li>
</ol>
<p>這個計算可以用 SQL（如果資料在資料庫）或 Python pandas（如果資料在 JSONL 檔案）完成。定期重算（每天或每週），產出使用者群體標籤。</p>
<p>RFM 分群需要的資料可以從自架 collector 提取 — <a href="/blog/monitoring/08-business-analytics/self-hosted-funnel/" data-link-title="從 collector 資料做基礎 funnel 分析" data-link-desc="SQLite 層能做什麼程度的 funnel、PostgreSQL 層提供什麼進階能力、JSONL 匯出後的臨時分析">從 collector 資料做基礎 funnel 分析</a>展示了 grep + jq 在自架環境中的分析能力和邊界。RFM 分出的群體還可以用 <a href="/blog/monitoring/08-business-analytics/cohort-analysis/" data-link-title="Cohort Analysis" data-link-desc="按共同特徵分群、比較不同群體的留存率和行為差異 — 從「平均值」到「誰在用、誰離開了」">Cohort analysis</a> 追蹤留存趨勢，兩種分析互補。分群和分析的前提是正確的<a href="/blog/monitoring/08-business-analytics/behavior-event-design/" data-link-title="行為事件設計" data-link-desc="事件命名規範、屬性設計、funnel 定義 — 行為分析的品質取決於事件設計的品質">行為事件設計</a> — 事件的屬性決定了 R/F/M 能否被計算。</p>
]]></content:encoded></item><item><title>模組七：資安與隱私</title><link>https://tarrragon.github.io/blog/monitoring/07-security-privacy/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/07-security-privacy/</guid><description>&lt;p>回答「蒐集的資料本身就是風險資產，怎麼保護」。三層防護：SDK 端 redaction → transport 加密 → collector access control。&lt;/p>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> SDK redaction API 設計（預設 redaction rule + 自訂 pattern）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Transport 安全（HTTPS / basic auth / 同區網也要加密的理由）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Collector access control 實作（認證 / 授權 / access log）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 去識別化策略（IP 截斷 / user agent 簡化 / stack trace 路徑清理 / session UUID）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> GDPR 最小化原則的工程落地&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 「監控資料洩漏」的 threat model&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Client-side SDK 認證的根本限制（credential 必然暴露、多層緩解策略）&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 07 資安&lt;/a>：server-side 的 secret management 跟本模組的 redaction 互補&lt;/li>
&lt;li>← &lt;a href="https://tarrragon.github.io/blog/ux-design/03-input-mechanism/" data-link-title="模組三：輸入機制設計" data-link-desc="Keyboard type / submit model / IME policy / special keys — 輸入機制是設計產物，影響 UI layout 和 protocol">ux-design 模組三 輸入機制&lt;/a>：IME 個人化學習 = secret 洩漏&lt;/li>
&lt;li>← &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>：log 內容可能含 secret，需要 redaction&lt;/li>
&lt;li>→ &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 工具到商業資產的翻轉">monitoring 模組八&lt;/a>：去識別化是商業利用的入場條件&lt;/li>
&lt;li>待建連結 → &lt;code>compliance/&lt;/code>（隱私法規教學分類）&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「蒐集的資料本身就是風險資產，怎麼保護」。三層防護：SDK 端 redaction → transport 加密 → collector access control。</p>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> SDK redaction API 設計（預設 redaction rule + 自訂 pattern）</li>
<li><input checked="" disabled="" type="checkbox"> Transport 安全（HTTPS / basic auth / 同區網也要加密的理由）</li>
<li><input checked="" disabled="" type="checkbox"> Collector access control 實作（認證 / 授權 / access log）</li>
<li><input checked="" disabled="" type="checkbox"> 去識別化策略（IP 截斷 / user agent 簡化 / stack trace 路徑清理 / session UUID）</li>
<li><input checked="" disabled="" type="checkbox"> GDPR 最小化原則的工程落地</li>
<li><input checked="" disabled="" type="checkbox"> 「監控資料洩漏」的 threat model</li>
<li><input checked="" disabled="" type="checkbox"> Client-side SDK 認證的根本限制（credential 必然暴露、多層緩解策略）</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 07 資安</a>：server-side 的 secret management 跟本模組的 redaction 互補</li>
<li>← <a href="/blog/ux-design/03-input-mechanism/" data-link-title="模組三：輸入機制設計" data-link-desc="Keyboard type / submit model / IME policy / special keys — 輸入機制是設計產物，影響 UI layout 和 protocol">ux-design 模組三 輸入機制</a>：IME 個人化學習 = secret 洩漏</li>
<li>← <a href="/blog/testing/02-client-observability/" data-link-title="模組二：客戶端可觀測性" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — log 設計是功能規格的一部分">testing 模組二 客戶端可觀測性</a>：log 內容可能含 secret，需要 redaction</li>
<li>→ <a href="/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">monitoring 模組八</a>：去識別化是商業利用的入場條件</li>
<li>待建連結 → <code>compliance/</code>（隱私法規教學分類）</li>
</ul>
]]></content:encoded></item><item><title>Client-side SDK 認證的根本限制</title><link>https://tarrragon.github.io/blog/monitoring/07-security-privacy/client-sdk-authentication/</link><pubDate>Wed, 24 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/07-security-privacy/client-sdk-authentication/</guid><description>&lt;p>當監控 SDK 部署在使用者裝置上（瀏覽器、手機 app、本機腳本），collector 的 ingestion endpoint 就暴露在外部網路 — 認證機制需要面對 credential 必然可被提取的前提。Client-side SDK 的認證和 server-side API 的認證面對的是結構性不同的問題。Server-side 的 API key 存在環境變數或 secret store 裡，只有 server process 能讀取。Client-side SDK 的 credential 必須嵌入到使用者手上的程式碼中 — JS bundle、APK、Python script — 使用者（或攻擊者）可以直接讀取。&lt;/p>
&lt;p>這個限制來自 architecture，和 implementation 無關。混淆 JS、ProGuard 混淆 APK、編譯 Python 成 &lt;code>.pyc&lt;/code>，都只增加提取成本，不改變「credential 在 client 端」的事實。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control&lt;/a> 討論了 API key 和 mTLS 的認證機制，&lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全&lt;/a> 討論了傳輸層加密。兩者的前提是 credential 被妥善保管。本章處理的是那個前提不成立時 — credential 已被提取或必然可被提取 — 的緩解策略。&lt;/p>
&lt;h2 id="商業方案的處理方式">商業方案的處理方式&lt;/h2>
&lt;p>所有主流的 client-side telemetry 方案都面對同樣的限制。它們的共同策略是：承認 client credential 會暴露，把防線從「保護 credential」轉移到「限制 credential 被濫用的影響」。&lt;/p>
&lt;p>&lt;strong>Google Analytics 4&lt;/strong>：Measurement ID（G-XXXXXXXXXX）直接寫在網頁的 JS snippet 中，任何人檢視網頁原始碼都能取得。GA4 的防護在 server-side — Google 用 domain 白名單過濾來源，加上自動的 bot traffic 偵測剔除機器流量。Measurement Protocol（server-to-server）需要額外的 API secret，但 client-side 的 gtag.js 不需要。&lt;/p>
&lt;p>&lt;strong>Sentry&lt;/strong>：DSN（Data Source Name）包含 project ID 和 public key，直接嵌在 SDK init 的程式碼中。Sentry 官方文件明確標示 DSN 是 public 的 — 攻擊者取得 DSN 只能送事件，不能讀取已收集的資料。防護靠 rate limit（每個 project 的 events/sec 上限）、allowed domains（只接受來自白名單 domain 的事件）、和 server-side 的 event 去重。&lt;/p>
&lt;p>&lt;strong>Firebase&lt;/strong>：整個 &lt;code>google-services.json&lt;/code> / &lt;code>GoogleService-Info.plist&lt;/code> 的內容 — 包含 apiKey、projectId、appId — 都視為公開資訊。Firebase 的安全模型不依賴這些 key 的保密性；它們的功能是識別（identify）而非授權（authorize）。需要保護的資源靠 Firebase Security Rules 和 App Check（device attestation）處理。&lt;/p>
&lt;p>&lt;strong>Datadog RUM&lt;/strong>：Client token 是獨立於 API key 的 credential。API key 可以讀寫所有 Datadog 資料，必須保護在 server-side；client token 只能寫入 RUM 事件，設計上可以暴露在 client 端。Datadog 建議搭配 intake proxy（collector 前面加一層自己的 server），讓 client token 不直接出現在瀏覽器中。&lt;/p></description><content:encoded><![CDATA[<p>當監控 SDK 部署在使用者裝置上（瀏覽器、手機 app、本機腳本），collector 的 ingestion endpoint 就暴露在外部網路 — 認證機制需要面對 credential 必然可被提取的前提。Client-side SDK 的認證和 server-side API 的認證面對的是結構性不同的問題。Server-side 的 API key 存在環境變數或 secret store 裡，只有 server process 能讀取。Client-side SDK 的 credential 必須嵌入到使用者手上的程式碼中 — JS bundle、APK、Python script — 使用者（或攻擊者）可以直接讀取。</p>
<p>這個限制來自 architecture，和 implementation 無關。混淆 JS、ProGuard 混淆 APK、編譯 Python 成 <code>.pyc</code>，都只增加提取成本，不改變「credential 在 client 端」的事實。</p>
<p><a href="/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control</a> 討論了 API key 和 mTLS 的認證機制，<a href="/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全</a> 討論了傳輸層加密。兩者的前提是 credential 被妥善保管。本章處理的是那個前提不成立時 — credential 已被提取或必然可被提取 — 的緩解策略。</p>
<h2 id="商業方案的處理方式">商業方案的處理方式</h2>
<p>所有主流的 client-side telemetry 方案都面對同樣的限制。它們的共同策略是：承認 client credential 會暴露，把防線從「保護 credential」轉移到「限制 credential 被濫用的影響」。</p>
<p><strong>Google Analytics 4</strong>：Measurement ID（G-XXXXXXXXXX）直接寫在網頁的 JS snippet 中，任何人檢視網頁原始碼都能取得。GA4 的防護在 server-side — Google 用 domain 白名單過濾來源，加上自動的 bot traffic 偵測剔除機器流量。Measurement Protocol（server-to-server）需要額外的 API secret，但 client-side 的 gtag.js 不需要。</p>
<p><strong>Sentry</strong>：DSN（Data Source Name）包含 project ID 和 public key，直接嵌在 SDK init 的程式碼中。Sentry 官方文件明確標示 DSN 是 public 的 — 攻擊者取得 DSN 只能送事件，不能讀取已收集的資料。防護靠 rate limit（每個 project 的 events/sec 上限）、allowed domains（只接受來自白名單 domain 的事件）、和 server-side 的 event 去重。</p>
<p><strong>Firebase</strong>：整個 <code>google-services.json</code> / <code>GoogleService-Info.plist</code> 的內容 — 包含 apiKey、projectId、appId — 都視為公開資訊。Firebase 的安全模型不依賴這些 key 的保密性；它們的功能是識別（identify）而非授權（authorize）。需要保護的資源靠 Firebase Security Rules 和 App Check（device attestation）處理。</p>
<p><strong>Datadog RUM</strong>：Client token 是獨立於 API key 的 credential。API key 可以讀寫所有 Datadog 資料，必須保護在 server-side；client token 只能寫入 RUM 事件，設計上可以暴露在 client 端。Datadog 建議搭配 intake proxy（collector 前面加一層自己的 server），讓 client token 不直接出現在瀏覽器中。</p>
<p>這些方案的共同模式：client-side credential 的角色是「識別來源」而非「授權存取」。即使被提取，攻擊者能做的事被限縮在「寫入事件」— 影響可控。</p>
<h2 id="認證天花板識別-vs-授權">認證天花板：識別 vs 授權</h2>
<p><a href="/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control</a> 的 API key 同時承擔識別和授權 — 有 key 就能寫入，沒 key 就被拒絕。在 server-side 場景下這沒有問題，因為 key 不會暴露。</p>
<p>Client-side 場景需要拆開這兩個功能：</p>
<p><strong>識別（identification）</strong>：這個 request 來自哪個 app、哪個 SDK、哪個部署版本。識別資訊可以公開 — 它的價值是讓 collector 知道事件來自哪裡，用於 access log、per-app rate limit、和事件標記。</p>
<p><strong>授權（authorization）</strong>：這個 request 有沒有權限執行寫入操作。授權依賴 credential 的保密性 — 在 client-side 場景下，credential 保密性的天花板很低。</p>
<p>接受這個區分後，client-side SDK 的 API key 更接近「識別 token」。它的洩漏不是安全事件（像 server-side API key 洩漏那樣），而是預期中的狀態。防護的重點從「防止 key 洩漏」轉移到「限制 key 被濫用時的影響」。</p>
<h2 id="多層緩解策略">多層緩解策略</h2>
<p>以下各層按實作成本遞增排列。前面的層在多數場景下足夠，後面的層在 endpoint 暴露在公開網路且面對主動攻擊時才需要。</p>
<h3 id="第一層寫入限制collector-已有">第一層：寫入限制（collector 已有）</h3>
<p><a href="/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control</a> 的寫入限制 — rate limit、payload size limit、schema validation — 是第一層防護。這些機制不區分「合法 SDK」和「偽造 client」，對所有寫入請求一視同仁地施加約束。</p>
<p>Rate limit 限制每個 API key 的事件速率。Schema validation 拒絕不符合 <a href="/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">event.schema.json</a> 結構的 payload。兩者合起來把偽造流量的影響限制在「每秒 N 筆符合 schema 的事件」— 這個量級的資料汙染對 error tracking 的影響有限（error 事件靠 stack trace fingerprint 去重），對 funnel 分析的影響較大（行為事件的計數會被灌水）。</p>
<h3 id="第二層origin-驗證">第二層：Origin 驗證</h3>
<p>Web SDK 的 HTTP request 帶有瀏覽器自動附加的 <code>Origin</code> header。Collector 可以檢查 Origin 是否在白名單中。</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">func</span> <span class="nf">originCheck</span><span class="p">(</span><span class="nx">next</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span><span class="p">,</span> <span class="nx">allowed</span> <span class="p">[]</span><span class="kt">string</span><span class="p">)</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">allowedSet</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">bool</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">o</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">allowed</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">allowedSet</span><span class="p">[</span><span class="nx">o</span><span class="p">]</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">return</span> <span class="nx">http</span><span class="p">.</span><span class="nf">HandlerFunc</span><span class="p">(</span><span class="kd">func</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">origin</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;Origin&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">if</span> <span class="nx">origin</span> <span class="o">!=</span> <span class="s">&#34;&#34;</span> <span class="o">&amp;&amp;</span> <span class="p">!</span><span class="nx">allowedSet</span><span class="p">[</span><span class="nx">origin</span><span class="p">]</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;forbidden origin&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusForbidden</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="k">return</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 class="nx">next</span><span class="p">.</span><span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">r</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Origin 驗證擋住的是「從瀏覽器中跨域呼叫」的場景 — 攻擊者在自己的網站用 JS 向你的 collector 發 request，瀏覽器會帶上攻擊者網站的 Origin，被 collector 拒絕。</p>
<p><strong>天花板</strong>：Origin header 只有瀏覽器會自動附加。用 <code>curl</code>、Postman、或任何非瀏覽器 HTTP client 發 request 時，可以自行設定任意 Origin 值。Origin 驗證擋得住瀏覽器中的跨域呼叫，擋不住直接用 HTTP client 偽造的 request。</p>
<p>Mobile SDK（Flutter / native app）的 request 不帶 Origin header。Origin 驗證只對 Web SDK 有效。</p>
<h3 id="第三層request-signing">第三層：Request signing</h3>
<p>SDK 用 HMAC 對每個 request 簽章，collector 驗證簽章有效性。簽章的輸入包含 timestamp 和 payload hash，防止 replay attack 和 payload 竄改。</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">X-Signature: a3f8c2e1b7d94f06...  (HMAC-SHA256 結果的 hex 編碼)
</span></span><span class="line"><span class="ln">2</span><span class="cl">X-Timestamp: 1719216000</span></span></code></pre></div><p>SDK 計算方式：<code>HMAC-SHA256(secret, timestamp + &quot;.&quot; + SHA256(body))</code>，結果轉 hex 字串放入 <code>X-Signature</code> header。</p>
<p>Collector 端的驗證邏輯：</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">func</span> <span class="nf">verifySignature</span><span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">,</span> <span class="nx">secret</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ts</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;X-Timestamp&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">sig</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;X-Signature&#34;</span><span class="p">)</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">// 拒絕超過 5 分鐘的 request timestamp（防 replay）</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="c1">// 5 分鐘容忍 client-server 時鐘漂移和網路延遲；行動裝置偏差大的環境可放寬到 10 分鐘</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="c1">// 此處的 timestamp 是 HTTP request 發出時間，和事件的 timestamp 欄位（事件產生時間）無關</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">tsInt</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">strconv</span><span class="p">.</span><span class="nf">ParseInt</span><span class="p">(</span><span class="nx">ts</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">64</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="o">||</span> <span class="nf">abs</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">Unix</span><span class="p">()</span><span class="o">-</span><span class="nx">tsInt</span><span class="p">)</span> <span class="p">&gt;</span> <span class="mi">300</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span> <span class="kc">false</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="nx">body</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">io</span><span class="p">.</span><span class="nf">ReadAll</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">bodyHash</span> <span class="o">:=</span> <span class="nx">sha256</span><span class="p">.</span><span class="nf">Sum256</span><span class="p">(</span><span class="nx">body</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">expected</span> <span class="o">:=</span> <span class="nx">hmac</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="nx">sha256</span><span class="p">.</span><span class="nx">New</span><span class="p">,</span> <span class="p">[]</span><span class="nb">byte</span><span class="p">(</span><span class="nx">secret</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">expected</span><span class="p">.</span><span class="nf">Write</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="nx">ts</span> <span class="o">+</span> <span class="s">&#34;.&#34;</span> <span class="o">+</span> <span class="nx">hex</span><span class="p">.</span><span class="nf">EncodeToString</span><span class="p">(</span><span class="nx">bodyHash</span><span class="p">[:])))</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">sigBytes</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">hex</span><span class="p">.</span><span class="nf">DecodeString</span><span class="p">(</span><span class="nx">sig</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">return</span> <span class="nx">hmac</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="nx">sigBytes</span><span class="p">,</span> <span class="nx">expected</span><span class="p">.</span><span class="nf">Sum</span><span class="p">(</span><span class="kc">nil</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>Request signing 增加偽造成本 — 攻擊者需要提取 HMAC secret 並實作簽章邏輯，而非直接複製一個 API key 貼到 curl 指令。</p>
<p>HMAC secret 和 API key 一樣嵌在 client 端程式碼中，反編譯 APK 或閱讀 JS bundle 可以提取。Signing 增加的是攻擊者的工程投入（需要理解簽章算法並正確實作），而非理論上的安全性。對 casual attacker（看到 API key 就想試試的人）有效，對 motivated attacker（願意花時間逆向工程的人）無效。</p>
<h3 id="第四層行為分析異常偵測">第四層：行為分析異常偵測</h3>
<p>Collector 端統計每個 API key（或 source.app）的事件模式，建立 baseline 後偵測偏離。</p>
<p>正常 SDK 的行為有可預測的特徵：</p>
<table>
  <thead>
      <tr>
          <th>特徵</th>
          <th>正常 SDK 的 pattern</th>
          <th>偽造流量的 pattern</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>事件類型分布</td>
          <td>error / event / lifecycle / metric 四類混合</td>
          <td>可能只有單一類型</td>
      </tr>
      <tr>
          <td>事件間隔</td>
          <td>攢批送出，interval 接近 SDK config 的 flush interval</td>
          <td>固定間隔或連續送出</td>
      </tr>
      <tr>
          <td>Payload 結構</td>
          <td><code>source.sdk</code> / <code>source.platform</code> / <code>source.app</code> 值穩定</td>
          <td>可能缺少 SDK 自動填入的欄位</td>
      </tr>
      <tr>
          <td>Session 行為</td>
          <td>有 lifecycle 事件（session.begin / session.end）</td>
          <td>可能沒有 session 邊界</td>
      </tr>
      <tr>
          <td>時間分布</td>
          <td>跟使用者活動時段相關（工作時間 / 使用高峰）</td>
          <td>可能 24 小時均勻分布</td>
      </tr>
  </tbody>
</table>
<p>Collector 可以用 rule engine 偵測異常模式：</p>
<ul>
<li>單一 API key 的事件量在 10 分鐘內超過過去 24 小時平均值的 10 倍</li>
<li>連續 N 個 request 的事件全是同一個 type</li>
<li><code>source.sdk</code> 欄位的值不在已知的 SDK 版本清單中</li>
</ul>
<p>偵測到異常後的處理方式是標記而非丟棄 — 在事件中加入 <code>_flags.suspicious = true</code> flag，讓 dashboard 和分析查詢可以過濾。直接丟棄有誤殺正常流量的風險（例如行銷活動導致的真實流量暴增）。</p>
<p>攻擊者如果研究過正常 SDK 的行為模式（事件類型分布、送出間隔、payload 結構），可以模擬出相似的流量。行為分析依賴「偽造流量和正常流量有可偵測的差異」這個前提 — 對低投入的攻擊者成立，對高投入的攻擊者不一定。</p>
<h3 id="第五層device-attestation">第五層：Device attestation</h3>
<p>由作業系統或平台層驗證 client 的合法性，提供 SDK 自身無法產生的證明。</p>
<p><strong>Firebase App Check</strong>：整合 DeviceCheck（iOS）、Play Integrity（Android）、reCAPTCHA Enterprise（Web），由裝置平台出具 attestation token。Collector 向 Firebase 驗證 token 的有效性。</p>
<p><strong>Apple DeviceCheck / App Attest</strong>：iOS 裝置向 Apple server 請求 attestation，證明 request 來自一台真實的、未被篡改的 iOS 裝置上的合法 app。</p>
<p><strong>Google Play Integrity</strong>：驗證 request 來自 Google Play 安裝的 app、在未 root 的裝置上、由合法使用者操作。</p>
<p>Device attestation 提供的保證比前四層都強 — 它依賴裝置硬體和平台服務（難以偽造），而非 SDK 嵌入的 secret（可提取）。</p>
<p><strong>天花板</strong>：</p>
<ul>
<li>平台綁定 — 每個平台（iOS / Android / Web）需要各自整合不同的 attestation 服務，跨平台 SDK 的實作成本高</li>
<li>Root / 越獄裝置上 attestation 可能失敗或被繞過</li>
<li>Web 端的 reCAPTCHA 驗證依賴 Google 服務，有隱私和可用性的考量</li>
<li>自架 collector 需要額外整合 Firebase Admin SDK 或各平台的驗證 API</li>
</ul>
<p>Device attestation 適合商業產品級的 mobile app，對自架監控工具而言實作成本通常超出收益。</p>
<h2 id="自架方案的規模對應">自架方案的規模對應</h2>
<p>不同部署規模下，需要做到哪一層取決於 endpoint 的暴露程度和偽造流量的影響大小。</p>
<table>
  <thead>
      <tr>
          <th>部署場景</th>
          <th>暴露程度</th>
          <th>建議做到的層級</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自用（1 人，同機 / 同網段）</td>
          <td>低 — endpoint 不對外</td>
          <td>HTTPS + basic auth</td>
          <td>攻擊面只有同網段，認證足夠</td>
      </tr>
      <tr>
          <td>小型團隊（&lt; 100 人，VPN 內）</td>
          <td>低 — endpoint 在 VPN 後</td>
          <td>API key + rate limit</td>
          <td>VPN 已限制存取範圍，rate limit 防 SDK bug</td>
      </tr>
      <tr>
          <td>公開 endpoint（VPS / 雲端）</td>
          <td>高 — 任何人可存取</td>
          <td>第一到第四層 + WAF</td>
          <td>rate limit + origin + signing + 行為分析 + CDN/WAF 的 IP reputation 過濾</td>
      </tr>
      <tr>
          <td>商業產品（app store 發佈）</td>
          <td>高 — APK 可反編譯，JS 可檢視原始碼</td>
          <td>第一到第五層 + intake proxy</td>
          <td>需要 device attestation 和 proxy 層把 credential 從 client 端移除</td>
      </tr>
  </tbody>
</table>
<p><strong>Intake proxy 架構</strong>：在公開 endpoint 和商業產品場景下，可以在 collector 前面加一層自己的 server（proxy），SDK 送事件到 proxy，proxy 用 server-side API key 轉發到 collector。Client 端的 credential 只指向 proxy，proxy 的 API key 指向 collector — credential 分層，client 端的 key 洩漏不影響 collector 的認證。</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">SDK ──(client token)──→ Intake Proxy ──(server API key)──→ Collector</span></span></code></pre></div><p>Proxy 的額外成本是多一個 server 和網路跳躍。自用場景下不需要；endpoint 公開時值得考慮。</p>
<h2 id="偽造流量的影響分析">偽造流量的影響分析</h2>
<p>偽造流量進入 collector 後，對不同類型的分析影響不同。</p>
<p><strong>Error tracking 影響較低</strong>：error 事件的價值在 stack trace 和 error message。偽造的 error 事件缺少真實的 stack trace — 即使格式正確，內容是編造的。Error 去重靠 fingerprint（error type + message + stack trace top frame），偽造事件產生的 fingerprint 不會和真實 error 碰撞，在 dashboard 上是獨立的 error group，容易識別和過濾。</p>
<p><strong>行為分析影響較高</strong>：funnel 和 cohort 分析依賴事件計數的準確性。偽造的 <code>page.view</code> 和 <code>button.click</code> 事件直接灌水計數，導致轉換率失真。偽造事件越接近真實事件的結構（正確的 event name、合理的 timestamp），影響越大。</p>
<p><strong>資源消耗是固定成本</strong>：無論事件內容是否真實，每筆事件都消耗 collector 的寫入 I/O、儲存空間、和查詢時間。Rate limit 把這個成本限制在可控範圍 — 每秒 N 筆是上限，無論來源是否合法。</p>
<h3 id="事後標記策略">事後標記策略</h3>
<p>偵測到可疑流量後，collector 在事件中加入標記欄位而非直接丟棄。丟棄有誤殺風險 — 行銷活動的流量暴增、SDK 版本升級改變了事件模式、新平台的 SDK 上線 — 這些正常場景可能觸發異常偵測。</p>
<p>標記方式是在 collector 寫入時，對符合異常條件的事件附加 metadata：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;v&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;event&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;button.click&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nt">&#34;source&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;sdk&#34;</span><span class="p">:</span> <span class="s2">&#34;js&#34;</span><span class="p">,</span> <span class="nt">&#34;platform&#34;</span><span class="p">:</span> <span class="s2">&#34;web&#34;</span><span class="p">,</span> <span class="nt">&#34;app&#34;</span><span class="p">:</span> <span class="s2">&#34;main-site&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nt">&#34;_flags&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;suspicious&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&#34;reason&#34;</span><span class="p">:</span> <span class="s2">&#34;rate_anomaly&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Dashboard 查詢預設排除 <code>_flags.suspicious = true</code> 的事件。需要調查時可以包含 — 看可疑事件的模式有助於判斷是攻擊還是誤判。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Collector 端的認證和授權機制 → <a href="/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control 實作</a></li>
<li>Transport 層的加密保護 → <a href="/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全</a></li>
<li>Endpoint 濫用的威脅分析 → <a href="/blog/monitoring/07-security-privacy/monitoring-data-threat-model/" data-link-title="監控資料洩漏的 Threat Model" data-link-desc="監控系統本身是攻擊面 — 四個威脅場景（傳輸竊聽 / 儲存入侵 / endpoint 濫用 / 內部越權存取）的風險評估和防護措施">監控資料洩漏的 Threat Model</a></li>
<li>SDK 端的寫入速率控制 → <a href="/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling</a></li>
<li>行為分析和 rule engine → <a href="/blog/monitoring/04-collector/rule-engine/" data-link-title="Rule engine 設計" data-link-desc="條件 → 動作 → 模板的三段式規則結構 — 讓 collector 從被動儲存變成主動回應">Rule Engine 設計</a></li>
<li>偽造流量對資料完整性的影響 → <a href="/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性</a></li>
<li>Error fingerprint 讓偽造 error 容易辨識 → <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></li>
</ul>
]]></content:encoded></item><item><title>Rate Limiting</title><link>https://tarrragon.github.io/blog/monitoring/knowledge-cards/rate-limiting/</link><pubDate>Wed, 24 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/knowledge-cards/rate-limiting/</guid><description>&lt;p>速率限制（rate limiting）的通用概念見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">Backend 知識卡：Rate Limit&lt;/a> — 限制某個主體在一段時間內可使用的資源量。本卡聚焦監控系統中的具體實作：限制每個 client（API key / source.app）在單位時間內可送出的事件數量，保護 collector 不被單一 SDK 的 bug（事件風暴）或偽造流量消耗處理能力。可先對照 &lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="下游處理能力不足時向上游回傳「慢下來」訊號的流量控制機制 — 監控系統中 collector 用 HTTP 429 向 SDK 傳遞背壓">backpressure&lt;/a>（全域的容量訊號）和 &lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/sampling/" data-link-title="Sampling" data-link-desc="在事件產生階段按比例丟棄部分事件降低管線負載 — 分靜態取樣（config 固定比例）和動態取樣（背壓觸發自動降低）">sampling&lt;/a>（SDK 端的主動降載）。&lt;/p>
&lt;h2 id="和-backpressure-的差異">和 backpressure 的差異&lt;/h2>
&lt;p>Rate limiting 和 backpressure 都限制流量，但保護的維度不同。Rate limiting 是 per-client 的配額機制 — 每個 API key 有獨立的速率上限，一個 client 超限不影響其他 client。Backpressure 是全域的容量訊號 — collector 的寫入 channel 滿時對所有 client 回 429，不區分來源。一個 client 的失控用 rate limiting 處理（隔離問題源），全域流量過大用 backpressure 處理（全體降速）。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>Rate limiting 觸發的訊號是 collector 端對特定 API key 回 429 的次數上升、而其他 key 正常。典型場景：某個 SDK 版本有 bug 導致每秒產生 1000 筆事件 → per-key rate limiter 超過閾值 → 該 key 的後續 request 被回 429 → 其他 SDK 不受影響。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Rate limiting 承擔的設計責任是「在公平性和可用性之間取得平衡」。閾值設太低，正常的 burst flush（攢批後一次送出）會被誤觸；閾值設太高，失控的 SDK 要送很多筆才被擋。合理的閾值需高於正常 burst 的事件速率。&lt;/p>
&lt;h2 id="完整章節">完整章節&lt;/h2>
&lt;p>Per-SDK rate limiting 的實作 → &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling&lt;/a>。Rate limiting 在 collector access control 中的角色 → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control 實作&lt;/a>。偽造流量場景下 rate limiting 和其他防護層的配合 → &lt;a href="https://tarrragon.github.io/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 認證&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>速率限制（rate limiting）的通用概念見 <a href="/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">Backend 知識卡：Rate Limit</a> — 限制某個主體在一段時間內可使用的資源量。本卡聚焦監控系統中的具體實作：限制每個 client（API key / source.app）在單位時間內可送出的事件數量，保護 collector 不被單一 SDK 的 bug（事件風暴）或偽造流量消耗處理能力。可先對照 <a href="/blog/monitoring/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="下游處理能力不足時向上游回傳「慢下來」訊號的流量控制機制 — 監控系統中 collector 用 HTTP 429 向 SDK 傳遞背壓">backpressure</a>（全域的容量訊號）和 <a href="/blog/monitoring/knowledge-cards/sampling/" data-link-title="Sampling" data-link-desc="在事件產生階段按比例丟棄部分事件降低管線負載 — 分靜態取樣（config 固定比例）和動態取樣（背壓觸發自動降低）">sampling</a>（SDK 端的主動降載）。</p>
<h2 id="和-backpressure-的差異">和 backpressure 的差異</h2>
<p>Rate limiting 和 backpressure 都限制流量，但保護的維度不同。Rate limiting 是 per-client 的配額機制 — 每個 API key 有獨立的速率上限，一個 client 超限不影響其他 client。Backpressure 是全域的容量訊號 — collector 的寫入 channel 滿時對所有 client 回 429，不區分來源。一個 client 的失控用 rate limiting 處理（隔離問題源），全域流量過大用 backpressure 處理（全體降速）。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>Rate limiting 觸發的訊號是 collector 端對特定 API key 回 429 的次數上升、而其他 key 正常。典型場景：某個 SDK 版本有 bug 導致每秒產生 1000 筆事件 → per-key rate limiter 超過閾值 → 該 key 的後續 request 被回 429 → 其他 SDK 不受影響。</p>
<h2 id="設計責任">設計責任</h2>
<p>Rate limiting 承擔的設計責任是「在公平性和可用性之間取得平衡」。閾值設太低，正常的 burst flush（攢批後一次送出）會被誤觸；閾值設太高，失控的 SDK 要送很多筆才被擋。合理的閾值需高於正常 burst 的事件速率。</p>
<h2 id="完整章節">完整章節</h2>
<p>Per-SDK rate limiting 的實作 → <a href="/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling</a>。Rate limiting 在 collector access control 中的角色 → <a href="/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control 實作</a>。偽造流量場景下 rate limiting 和其他防護層的配合 → <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>。</p>
]]></content:encoded></item><item><title>DevOps Dashboard 設計</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/dashboard-devops/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/dashboard-devops/</guid><description>&lt;p>DevOps dashboard 的消費者是維護 collector 的人 — 可能是開發者自己、可能是開源使用者的運維人員。這個 dashboard 不看被監控 app 的業務邏輯，只看 collector 這個基礎設施本身是否健康、各 SDK 實例是否正常回報。&lt;/p>
&lt;p>使用模式是混合型：平時靠告警被動通知，收到通知後到 dashboard 查看細節。日常監控視圖提供「一眼確認系統正常」的能力，告警觸發視圖提供「出事了去哪裡查」的排障路徑。&lt;/p>
&lt;h2 id="日常監控視圖">日常監控視圖&lt;/h2>
&lt;h3 id="服務狀態卡">服務狀態卡&lt;/h3>
&lt;p>一個狀態卡顯示 collector 的存活狀態和各 SDK 實例的最後心跳時間。狀態卡的設計是「綠色代表正常、紅色代表異常」的二元判斷 — 不需要使用者解讀數字。&lt;/p>
&lt;p>Collector 存活的判斷依據是 health endpoint 回應。各 SDK 實例的狀態依據是最後一次 &lt;code>sdk.heartbeat&lt;/code> 事件的時間 — 超過設定的逾時閾值（預設 10 分鐘）標為離線。&lt;/p>
&lt;p>需要的事件：&lt;code>collector.health.check&lt;/code>（collector 自身定期產生）、&lt;code>sdk.heartbeat&lt;/code>（各 SDK 定期送出）、&lt;code>sdk.init&lt;/code>（SDK 啟動時送出、標記上線）。&lt;/p>
&lt;h3 id="吞吐量曲線">吞吐量曲線&lt;/h3>
&lt;p>折線圖顯示過去 24 小時每分鐘收到的事件數量。多個 SDK 實例用不同顏色區分。吞吐量的正常範圍由歷史資料建立基線 — 突然下降代表某個 SDK 停止送資料，突然上升代表 error storm 或重複送出。&lt;/p>
&lt;p>需要的事件：&lt;code>collector.ingestion.count&lt;/code>（collector 每分鐘記錄收到的事件數，按 source.app 分群）。&lt;/p>
&lt;h3 id="儲存用量">儲存用量&lt;/h3>
&lt;p>磁碟使用率的趨勢圖 + 保留策略的執行狀態。開發者需要知道「磁碟什麼時候會滿」和「purge 有沒有正常跑」。&lt;/p>
&lt;p>需要的事件：&lt;code>collector.storage.disk_usage&lt;/code>（定期取樣、metric 類型）、&lt;code>collector.storage.purge.completed&lt;/code>（每次 purge 完成時記錄清了多少空間）。&lt;/p>
&lt;h3 id="sdk-連線列表">SDK 連線列表&lt;/h3>
&lt;p>表格列出所有已知的 SDK 實例，每行顯示：app 名稱、版本、平台、最後回報時間、最後一次 init 時間。表格按「最後回報時間」排序 — 最久沒回報的在最上面，方便發現異常。&lt;/p>
&lt;p>需要的事件：&lt;code>sdk.init&lt;/code>（帶 source 完整資訊）、&lt;code>sdk.heartbeat&lt;/code>（定期更新最後回報時間）。&lt;/p>
&lt;p>Heartbeat 的觸發機制是 flush timer 的副作用 — SDK 的 flush timer 觸發時，如果 buffer 為空且距上次 heartbeat 超過設定間隔（預設 5 分鐘），自動注入一筆 &lt;code>sdk.heartbeat&lt;/code> 事件後送出。不需要獨立的 heartbeat timer。App idle 時 heartbeat 仍會送出，dashboard 的 SDK 連線列表因此能偵測 SDK 是否仍存活。&lt;/p>
&lt;h2 id="告警觸發視圖">告警觸發視圖&lt;/h2>
&lt;p>告警由 rule engine 觸發，觸發後開發者進入 dashboard 查看細節。每種告警條件對應一個排障路徑。&lt;/p>
&lt;h3 id="health-check-失敗">Health check 失敗&lt;/h3>
&lt;p>Collector 的 health endpoint 連續 N 次回應失敗（由外部 uptime check 偵測、如 cron + curl）。&lt;/p>
&lt;p>進入 dashboard 後看：最後一次 &lt;code>collector.health.check&lt;/code> 的時間和結果、collector 的 stderr log（systemd journal）、process 是否存活。如果 collector 已經掛了，dashboard 本身也不可達 — 這時的排障路徑是 SSH 到主機查 systemd 狀態。&lt;/p>
&lt;h3 id="sdk-停止回報">SDK 停止回報&lt;/h3>
&lt;p>某個 SDK 實例超過逾時閾值沒有送 &lt;code>sdk.heartbeat&lt;/code>。可能原因：被監控 app 當掉、網路斷開、SDK 初始化失敗。&lt;/p>
&lt;p>進入 dashboard 後看：該 SDK 的最後事件（什麼類型、什麼時間）、最後 &lt;code>sdk.init&lt;/code> 的 source 資訊（版本、平台）、同時段其他 SDK 是否正常（區分「單一 SDK 問題」和「collector 端問題」）。&lt;/p>
&lt;h3 id="磁碟用量超過閾值">磁碟用量超過閾值&lt;/h3>
&lt;p>&lt;code>collector.storage.disk_usage&lt;/code> 超過 80%。&lt;/p>
&lt;p>進入 dashboard 後看：各 backend 的空間佔比（SQLite DB 大小 + 匯出檔大小）、最近一次 purge 的執行時間和清理量、保留策略的設定值。如果 purge 正常執行但空間仍不足，代表事件產生速度超過清理速度 — 需要調整保留策略或擴容磁碟。&lt;/p>
&lt;h3 id="事件吞吐量異常下降">事件吞吐量異常下降&lt;/h3>
&lt;p>每分鐘事件數從正常基線突然下降超過 50%。&lt;/p></description><content:encoded><![CDATA[<p>DevOps dashboard 的消費者是維護 collector 的人 — 可能是開發者自己、可能是開源使用者的運維人員。這個 dashboard 不看被監控 app 的業務邏輯，只看 collector 這個基礎設施本身是否健康、各 SDK 實例是否正常回報。</p>
<p>使用模式是混合型：平時靠告警被動通知，收到通知後到 dashboard 查看細節。日常監控視圖提供「一眼確認系統正常」的能力，告警觸發視圖提供「出事了去哪裡查」的排障路徑。</p>
<h2 id="日常監控視圖">日常監控視圖</h2>
<h3 id="服務狀態卡">服務狀態卡</h3>
<p>一個狀態卡顯示 collector 的存活狀態和各 SDK 實例的最後心跳時間。狀態卡的設計是「綠色代表正常、紅色代表異常」的二元判斷 — 不需要使用者解讀數字。</p>
<p>Collector 存活的判斷依據是 health endpoint 回應。各 SDK 實例的狀態依據是最後一次 <code>sdk.heartbeat</code> 事件的時間 — 超過設定的逾時閾值（預設 10 分鐘）標為離線。</p>
<p>需要的事件：<code>collector.health.check</code>（collector 自身定期產生）、<code>sdk.heartbeat</code>（各 SDK 定期送出）、<code>sdk.init</code>（SDK 啟動時送出、標記上線）。</p>
<h3 id="吞吐量曲線">吞吐量曲線</h3>
<p>折線圖顯示過去 24 小時每分鐘收到的事件數量。多個 SDK 實例用不同顏色區分。吞吐量的正常範圍由歷史資料建立基線 — 突然下降代表某個 SDK 停止送資料，突然上升代表 error storm 或重複送出。</p>
<p>需要的事件：<code>collector.ingestion.count</code>（collector 每分鐘記錄收到的事件數，按 source.app 分群）。</p>
<h3 id="儲存用量">儲存用量</h3>
<p>磁碟使用率的趨勢圖 + 保留策略的執行狀態。開發者需要知道「磁碟什麼時候會滿」和「purge 有沒有正常跑」。</p>
<p>需要的事件：<code>collector.storage.disk_usage</code>（定期取樣、metric 類型）、<code>collector.storage.purge.completed</code>（每次 purge 完成時記錄清了多少空間）。</p>
<h3 id="sdk-連線列表">SDK 連線列表</h3>
<p>表格列出所有已知的 SDK 實例，每行顯示：app 名稱、版本、平台、最後回報時間、最後一次 init 時間。表格按「最後回報時間」排序 — 最久沒回報的在最上面，方便發現異常。</p>
<p>需要的事件：<code>sdk.init</code>（帶 source 完整資訊）、<code>sdk.heartbeat</code>（定期更新最後回報時間）。</p>
<p>Heartbeat 的觸發機制是 flush timer 的副作用 — SDK 的 flush timer 觸發時，如果 buffer 為空且距上次 heartbeat 超過設定間隔（預設 5 分鐘），自動注入一筆 <code>sdk.heartbeat</code> 事件後送出。不需要獨立的 heartbeat timer。App idle 時 heartbeat 仍會送出，dashboard 的 SDK 連線列表因此能偵測 SDK 是否仍存活。</p>
<h2 id="告警觸發視圖">告警觸發視圖</h2>
<p>告警由 rule engine 觸發，觸發後開發者進入 dashboard 查看細節。每種告警條件對應一個排障路徑。</p>
<h3 id="health-check-失敗">Health check 失敗</h3>
<p>Collector 的 health endpoint 連續 N 次回應失敗（由外部 uptime check 偵測、如 cron + curl）。</p>
<p>進入 dashboard 後看：最後一次 <code>collector.health.check</code> 的時間和結果、collector 的 stderr log（systemd journal）、process 是否存活。如果 collector 已經掛了，dashboard 本身也不可達 — 這時的排障路徑是 SSH 到主機查 systemd 狀態。</p>
<h3 id="sdk-停止回報">SDK 停止回報</h3>
<p>某個 SDK 實例超過逾時閾值沒有送 <code>sdk.heartbeat</code>。可能原因：被監控 app 當掉、網路斷開、SDK 初始化失敗。</p>
<p>進入 dashboard 後看：該 SDK 的最後事件（什麼類型、什麼時間）、最後 <code>sdk.init</code> 的 source 資訊（版本、平台）、同時段其他 SDK 是否正常（區分「單一 SDK 問題」和「collector 端問題」）。</p>
<h3 id="磁碟用量超過閾值">磁碟用量超過閾值</h3>
<p><code>collector.storage.disk_usage</code> 超過 80%。</p>
<p>進入 dashboard 後看：各 backend 的空間佔比（SQLite DB 大小 + 匯出檔大小）、最近一次 purge 的執行時間和清理量、保留策略的設定值。如果 purge 正常執行但空間仍不足，代表事件產生速度超過清理速度 — 需要調整保留策略或擴容磁碟。</p>
<h3 id="事件吞吐量異常下降">事件吞吐量異常下降</h3>
<p>每分鐘事件數從正常基線突然下降超過 50%。</p>
<p>進入 dashboard 後看：吞吐量曲線標注「下降起始時間」、SDK 連線列表確認哪些 SDK 在該時間點後停止回報、collector 的 ingestion error log。</p>
<h2 id="需要的事件總表">需要的事件總表</h2>
<table>
  <thead>
      <tr>
          <th>事件名稱</th>
          <th>類型</th>
          <th>產生者</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>collector.health.check</td>
          <td>lifecycle</td>
          <td>Collector</td>
          <td>服務狀態卡</td>
      </tr>
      <tr>
          <td>collector.started</td>
          <td>lifecycle</td>
          <td>Collector</td>
          <td>部署追蹤</td>
      </tr>
      <tr>
          <td>collector.shutdown</td>
          <td>lifecycle</td>
          <td>Collector</td>
          <td>異常關閉偵測</td>
      </tr>
      <tr>
          <td>collector.ingestion.count</td>
          <td>metric</td>
          <td>Collector</td>
          <td>吞吐量曲線</td>
      </tr>
      <tr>
          <td>collector.storage.disk_usage</td>
          <td>metric</td>
          <td>Collector</td>
          <td>儲存用量圖</td>
      </tr>
      <tr>
          <td>collector.storage.purge.completed</td>
          <td>lifecycle</td>
          <td>Collector</td>
          <td>purge 執行記錄</td>
      </tr>
      <tr>
          <td>sdk.heartbeat</td>
          <td>lifecycle</td>
          <td>SDK</td>
          <td>連線列表、存活判斷</td>
      </tr>
      <tr>
          <td>sdk.init</td>
          <td>lifecycle</td>
          <td>SDK</td>
          <td>版本/平台資訊、上線記錄</td>
      </tr>
      <tr>
          <td>deployment.started</td>
          <td>lifecycle</td>
          <td>CI/CD hook</td>
          <td>部署追蹤</td>
      </tr>
      <tr>
          <td>deployment.completed</td>
          <td>lifecycle</td>
          <td>CI/CD hook</td>
          <td>部署追蹤</td>
      </tr>
      <tr>
          <td>rule.matched</td>
          <td>event</td>
          <td>Collector</td>
          <td>alert 歷史</td>
      </tr>
  </tbody>
</table>
<p>這些事件是 collector 自身的營運事件，和被監控 app 的事件走同一個 Storage interface 儲存。Collector 同時是事件的生產者和消費者 — <code>collector.ingestion.count</code> 由 collector 自己產生、自己儲存、自己在 dashboard 顯示。</p>
<p><code>deployment.started</code> / <code>deployment.completed</code> 這兩個 lifecycle event 在 server-side 部署流程中對應 <a href="/blog/backend/05-deployment-platform/deployment-rollout-drain-rollback/" data-link-title="5.8 Deployment Rollout with Drain and Rollback（實作示範）" data-link-desc="以 checkout service 示範部署切換如何交付 canary evidence、drain signal、release gate 與 incident decision log。">Backend 5.8 Deployment Rollout</a> 的 evidence package——rollout 的每一批切換需要可判讀的部署事件作為證據。自架 collector 場景的部署追蹤規模遠小於 production server-side rollout，但 event schema 設計（timestamp / version / environment / result）可以跟 server-side 的 evidence 欄位對齊，讓未來規模成長時 event 格式不用重新設計。</p>
<h2 id="自動恢復設計">自動恢復設計</h2>
<p>自用工具場景下「凌晨三點 collector 掛了」的處理策略是自動恢復，不需要人介入。</p>
<table>
  <thead>
      <tr>
          <th>機制</th>
          <th>做法</th>
          <th>恢復時間</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>systemd watchdog</td>
          <td><code>WatchdogSec=30s</code>，collector 定期寫 watchdog notify</td>
          <td>30 秒內重啟</td>
      </tr>
      <tr>
          <td>Restart policy</td>
          <td><code>Restart=on-failure</code>、<code>RestartSec=5s</code></td>
          <td>5 秒後自動重啟</td>
      </tr>
      <tr>
          <td>Health endpoint</td>
          <td><code>/health</code> 回應 200 + 最後寫入時間</td>
          <td>外部 check 偵測</td>
      </tr>
      <tr>
          <td>啟動自檢</td>
          <td>collector 啟動時檢查 storage 完整性、重建索引</td>
          <td>啟動時自動修復</td>
      </tr>
  </tbody>
</table>
<p>自動恢復後 collector 送出 <code>collector.started</code> 事件，dashboard 的服務狀態卡從紅轉綠。如果連續重啟（10 分鐘內重啟 3 次以上），systemd 的 <code>StartLimitBurst</code> 阻止無限重啟、改為發送告警通知人工介入。</p>
<h2 id="存取控制">存取控制</h2>
<p>Day-one 的 dashboard 預設無認證 — 同區網內的任何裝置都能打開 dashboard URL。這是同區網信任模型的設計選擇，和 collector 的 HTTP endpoint 無認證一致。</p>
<h3 id="風險告知">風險告知</h3>
<p>無認證的 dashboard 暴露以下資訊給同區網的所有裝置：</p>
<ul>
<li><strong>DevOps dashboard</strong>：SDK 版本、平台、IP、collector 的磁碟用量</li>
<li><strong>Developer dashboard</strong>：error stack trace（可能包含檔案路徑和程式碼片段）、session 回放（使用者操作序列）</li>
<li><strong>中台 dashboard</strong>：行為事件明細、funnel 轉換率</li>
</ul>
<p>家用 LAN 的場景下，家裡的其他裝置（IoT、家人的電腦）也能存取這些資訊。</p>
<h3 id="最小防護">最小防護</h3>
<p>Go 的 <code>net/http</code> middleware 可以用幾行程式碼加 basic auth：</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">func</span> <span class="nf">basicAuth</span><span class="p">(</span><span class="nx">next</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span><span class="p">,</span> <span class="nx">user</span><span class="p">,</span> <span class="nx">pass</span> <span class="kt">string</span><span class="p">)</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">return</span> <span class="nx">http</span><span class="p">.</span><span class="nf">HandlerFunc</span><span class="p">(</span><span class="kd">func</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">u</span><span class="p">,</span> <span class="nx">p</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nf">BasicAuth</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="o">||</span> <span class="nx">u</span> <span class="o">!=</span> <span class="nx">user</span> <span class="o">||</span> <span class="nx">p</span> <span class="o">!=</span> <span class="nx">pass</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="nx">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;WWW-Authenticate&#34;</span><span class="p">,</span> <span class="s">`Basic realm=&#34;monitor&#34;`</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;Unauthorized&#34;</span><span class="p">,</span> <span class="mi">401</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">next</span><span class="p">.</span><span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">r</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>帳密在 collector 的配置檔設定。Day-one 可選（不設就不啟用），但配置檔中應有 commented-out 的範例讓使用者知道這個選項存在。</p>
<h3 id="tripwire">Tripwire</h3>
<p>Collector 暴露到公網或跨網路存取時，dashboard 的認證從可選變成必要。公網上的無認證 dashboard 等於公開了 error stack trace 和行為資料。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Developer dashboard 設計 → <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>中台 dashboard 設計 → <a href="/blog/monitoring/04-collector/dashboard-business/" data-link-title="中台 Dashboard 設計" data-link-desc="使用者怎麼用、在哪流失、怎麼讓他們回來 — 營運和行銷的日常指標監控與深入分析視圖，全部需要 PostgreSQL 層">中台 Dashboard 設計</a></li>
<li>Rule engine 的告警設計 → <a href="/blog/monitoring/04-collector/rule-engine/" data-link-title="Rule engine 設計" data-link-desc="條件 → 動作 → 模板的三段式規則結構 — 讓 collector 從被動儲存變成主動回應">Rule engine 設計</a></li>
<li>Collector 自我監控的 bootstrapping 問題 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a></li>
<li>服務探活與自動恢復 → <a href="/blog/devops/04-service-health/" data-link-title="模組四：服務探活與自動恢復" data-link-desc="服務掛了怎麼自動發現和恢復 — health check 設計、liveness vs readiness、systemd watchdog、process supervisor">DevOps 服務探活</a></li>
</ul>
]]></content:encoded></item><item><title>從 collector 資料做基礎 funnel 分析</title><link>https://tarrragon.github.io/blog/monitoring/08-business-analytics/self-hosted-funnel/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/08-business-analytics/self-hosted-funnel/</guid><description>&lt;p>自架 collector 收集的事件資料可以做基礎的 funnel 分析，不需要商業方案。分析的深度取決於 storage backend 的查詢能力 — SQLite 層能做每步事件計數，PostgreSQL 層能做 session 級轉換率分析。功能分層的完整定義見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇&lt;/a>。&lt;/p>
&lt;h2 id="定義-funnel-步驟">定義 funnel 步驟&lt;/h2>
&lt;p>Funnel 分析的第一步是列出每一步和對應的事件名稱。以一個透過 WebSocket 連接遠端終端機的 app 連線流程為例：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>步驟&lt;/th>
 &lt;th>事件名稱&lt;/th>
 &lt;th>意義&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>terminal.connect.start&lt;/td>
 &lt;td>使用者點擊連線&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>auth.biometric.success&lt;/td>
 &lt;td>生物辨識通過&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>terminal.connect.done&lt;/td>
 &lt;td>WebSocket 連線成功&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4&lt;/td>
 &lt;td>terminal.input.submit&lt;/td>
 &lt;td>使用者開始打字&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="sqlite-層每步事件計數">SQLite 層：每步事件計數&lt;/h2>
&lt;p>SQLite backend 能做的 funnel 是「每步有多少事件觸發」— 單表 GROUP BY，不需要跨事件 JOIN。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">as&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">events&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;terminal.connect.start&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;auth.biometric.success&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;terminal.connect.done&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;terminal.input.submit&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">datetime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;now&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;-7 days&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>步驟 N 的轉換率 = 步驟 N 的事件數 / 步驟 N-1 的事件數。流失率 = 1 - 轉換率。&lt;/p>
&lt;h3 id="能做的">能做的&lt;/h3>
&lt;ul>
&lt;li>每步事件計數（單表 GROUP BY）&lt;/li>
&lt;li>按 source.version 或 source.platform 分群（加 WHERE 條件）&lt;/li>
&lt;li>按天/按週看趨勢（strftime 分桶 + GROUP BY）&lt;/li>
&lt;/ul>
&lt;h3 id="做不到的">做不到的&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Session 級轉換率&lt;/strong>：「同一個 session 完成步驟 1 到步驟 4 的比例」需要 JOIN 同 session 的多個事件、跨所有 session 聚合。SQLite 能做這個 JOIN，但在大量 session 時效能不足。&lt;/li>
&lt;li>&lt;strong>步驟間耗時&lt;/strong>：「使用者在步驟 1 和步驟 2 之間等了多久」需要 self-join on session_id + timestamp 差值計算。&lt;/li>
&lt;li>&lt;strong>漏斗順序驗證&lt;/strong>：確認使用者是按 1→2→3→4 順序完成、不是跳步。&lt;/li>
&lt;/ul>
&lt;h2 id="postgresql-層session-級-funnel">PostgreSQL 層：Session 級 funnel&lt;/h2>
&lt;p>PostgreSQL backend 提供 window function 和高效 JOIN，能做完整的 session 級 funnel 分析。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">session_steps&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">session_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">ROW_NUMBER&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">OVER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">PARTITION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">session_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">as&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">step_order&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">events&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;terminal.connect.start&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;auth.biometric.success&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;terminal.connect.done&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;terminal.input.submit&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">NOW&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;7 days&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="n">session_max_step&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">session_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MAX&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">step_order&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">as&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">reached&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">session_steps&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">session_id&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">reached&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">as&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">sessions&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">session_max_step&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">reached&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">reached&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="新增能力">新增能力&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Session 級轉換率&lt;/strong>：每個 session 到達了哪一步、在哪一步流失&lt;/li>
&lt;li>&lt;strong>步驟間耗時&lt;/strong>：LAG window function 計算相鄰步驟的 timestamp 差值&lt;/li>
&lt;li>&lt;strong>漏斗順序驗證&lt;/strong>：用 ROW_NUMBER + CASE 確認步驟順序&lt;/li>
&lt;li>&lt;strong>Cohort 分群的 funnel&lt;/strong>：按使用者註冊日期 / 版本 / 平台分群看不同 cohort 的 funnel 差異&lt;/li>
&lt;/ul>
&lt;h2 id="jsonl-匯出後的臨時分析">JSONL 匯出後的臨時分析&lt;/h2>
&lt;p>Collector 的 &lt;code>monitor export --format=jsonl&lt;/code> 可以匯出事件為 JSONL 格式。匯出後用 grep + jq 做一次性的臨時分析：&lt;/p></description><content:encoded><![CDATA[<p>自架 collector 收集的事件資料可以做基礎的 funnel 分析，不需要商業方案。分析的深度取決於 storage backend 的查詢能力 — SQLite 層能做每步事件計數，PostgreSQL 層能做 session 級轉換率分析。功能分層的完整定義見 <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a>。</p>
<h2 id="定義-funnel-步驟">定義 funnel 步驟</h2>
<p>Funnel 分析的第一步是列出每一步和對應的事件名稱。以一個透過 WebSocket 連接遠端終端機的 app 連線流程為例：</p>
<table>
  <thead>
      <tr>
          <th>步驟</th>
          <th>事件名稱</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>terminal.connect.start</td>
          <td>使用者點擊連線</td>
      </tr>
      <tr>
          <td>2</td>
          <td>auth.biometric.success</td>
          <td>生物辨識通過</td>
      </tr>
      <tr>
          <td>3</td>
          <td>terminal.connect.done</td>
          <td>WebSocket 連線成功</td>
      </tr>
      <tr>
          <td>4</td>
          <td>terminal.input.submit</td>
          <td>使用者開始打字</td>
      </tr>
  </tbody>
</table>
<h2 id="sqlite-層每步事件計數">SQLite 層：每步事件計數</h2>
<p>SQLite backend 能做的 funnel 是「每步有多少事件觸發」— 單表 GROUP BY，不需要跨事件 JOIN。</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="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">as</span><span class="w"> </span><span class="k">count</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">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;terminal.connect.start&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;auth.biometric.success&#39;</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="s1">&#39;terminal.connect.done&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;terminal.input.submit&#39;</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="k">AND</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-7 days&#39;</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">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></span></code></pre></div><p>步驟 N 的轉換率 = 步驟 N 的事件數 / 步驟 N-1 的事件數。流失率 = 1 - 轉換率。</p>
<h3 id="能做的">能做的</h3>
<ul>
<li>每步事件計數（單表 GROUP BY）</li>
<li>按 source.version 或 source.platform 分群（加 WHERE 條件）</li>
<li>按天/按週看趨勢（strftime 分桶 + GROUP BY）</li>
</ul>
<h3 id="做不到的">做不到的</h3>
<ul>
<li><strong>Session 級轉換率</strong>：「同一個 session 完成步驟 1 到步驟 4 的比例」需要 JOIN 同 session 的多個事件、跨所有 session 聚合。SQLite 能做這個 JOIN，但在大量 session 時效能不足。</li>
<li><strong>步驟間耗時</strong>：「使用者在步驟 1 和步驟 2 之間等了多久」需要 self-join on session_id + timestamp 差值計算。</li>
<li><strong>漏斗順序驗證</strong>：確認使用者是按 1→2→3→4 順序完成、不是跳步。</li>
</ul>
<h2 id="postgresql-層session-級-funnel">PostgreSQL 層：Session 級 funnel</h2>
<p>PostgreSQL backend 提供 window function 和高效 JOIN，能做完整的 session 級 funnel 分析。</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">WITH</span><span class="w"> </span><span class="n">session_steps</span><span class="w"> </span><span class="k">AS</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="k">SELECT</span><span class="w"> </span><span class="n">session_id</span><span class="p">,</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 class="n">ROW_NUMBER</span><span class="p">()</span><span class="w"> </span><span class="n">OVER</span><span class="w"> </span><span class="p">(</span><span class="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">session_id</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="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">step_order</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="k">WHERE</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;terminal.connect.start&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;auth.biometric.success&#39;</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="s1">&#39;terminal.connect.done&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;terminal.input.submit&#39;</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="k">AND</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">NOW</span><span class="p">()</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="nb">INTERVAL</span><span class="w"> </span><span class="s1">&#39;7 days&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></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">session_max_step</span><span class="w"> </span><span class="k">AS</span><span class="w"> </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="k">SELECT</span><span class="w"> </span><span class="n">session_id</span><span class="p">,</span><span class="w"> </span><span class="k">MAX</span><span class="p">(</span><span class="n">step_order</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">reached</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">  </span><span class="k">FROM</span><span class="w"> </span><span class="n">session_steps</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><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">session_id</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w"></span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">reached</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">as</span><span class="w"> </span><span class="n">sessions</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">FROM</span><span class="w"> </span><span class="n">session_max_step</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><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">reached</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</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">reached</span><span class="p">;</span></span></span></code></pre></div><h3 id="新增能力">新增能力</h3>
<ul>
<li><strong>Session 級轉換率</strong>：每個 session 到達了哪一步、在哪一步流失</li>
<li><strong>步驟間耗時</strong>：LAG window function 計算相鄰步驟的 timestamp 差值</li>
<li><strong>漏斗順序驗證</strong>：用 ROW_NUMBER + CASE 確認步驟順序</li>
<li><strong>Cohort 分群的 funnel</strong>：按使用者註冊日期 / 版本 / 平台分群看不同 cohort 的 funnel 差異</li>
</ul>
<h2 id="jsonl-匯出後的臨時分析">JSONL 匯出後的臨時分析</h2>
<p>Collector 的 <code>monitor export --format=jsonl</code> 可以匯出事件為 JSONL 格式。匯出後用 grep + jq 做一次性的臨時分析：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">for</span> step in terminal.connect.start auth.biometric.success terminal.connect.done terminal.input.submit<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nv">count</span><span class="o">=</span><span class="k">$(</span>grep <span class="s2">&#34;\&#34;name\&#34;:\&#34;</span><span class="nv">$step</span><span class="s2">\&#34;&#34;</span> exported-events.jsonl <span class="p">|</span> wc -l<span class="k">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$step</span><span class="s2">: </span><span class="nv">$count</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">done</span></span></span></code></pre></div><p>JSONL 臨時分析適合「快速看一眼大概數字」的場景。持續性的 funnel 監控應該用 SQLite 或 PostgreSQL 的 SQL 查詢，結果穩定且可重現。</p>
<h2 id="自架-vs-商業方案">自架 vs 商業方案</h2>
<table>
  <thead>
      <tr>
          <th>需求</th>
          <th>自架能力</th>
          <th>商業方案</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>每步事件計數</td>
          <td>SQLite GROUP BY</td>
          <td>Mixpanel / Amplitude 內建</td>
      </tr>
      <tr>
          <td>Session 級轉換率</td>
          <td>PostgreSQL window function</td>
          <td>Mixpanel / Amplitude 內建</td>
      </tr>
      <tr>
          <td>視覺化 funnel 漏斗圖</td>
          <td>自建 dashboard</td>
          <td>商業方案內建、拖拉設定</td>
      </tr>
      <tr>
          <td>即時更新</td>
          <td>定期重算 + dashboard 刷新</td>
          <td>商業方案即時</td>
      </tr>
      <tr>
          <td>A/B test 分群 funnel</td>
          <td>PostgreSQL + feature flag</td>
          <td>Optimizely / LaunchDarkly 整合</td>
      </tr>
  </tbody>
</table>
<p>自用工具場景下，SQLite 層的每步事件計數通常足夠。商業產品需要 session 級分析時，PostgreSQL 層的 SQL 能力和商業方案的分析能力在功能上對等，差異在 UI 和設定便利性。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Funnel 分析的完整方法論 → <a href="/blog/monitoring/08-business-analytics/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="使用者在哪一步流失 — 從事件序列計算每步轉換率、找出流失最嚴重的步驟、區分設計問題和技術問題">Funnel analysis</a></li>
<li>事件設計如何影響分析品質 → <a href="/blog/monitoring/08-business-analytics/behavior-event-design/" data-link-title="行為事件設計" data-link-desc="事件命名規範、屬性設計、funnel 定義 — 行為分析的品質取決於事件設計的品質">行為事件設計</a></li>
<li>功能分層定義 → <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a></li>
<li>去識別化是分析的入場條件 → <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">模組七 資安與隱私</a></li>
</ul>
]]></content:encoded></item><item><title>模組八：行為資料的商業利用</title><link>https://tarrragon.github.io/blog/monitoring/08-business-analytics/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/08-business-analytics/</guid><description>&lt;p>回答「蒐集到的行為資料除了 debug，還能做什麼」。前提：&lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">模組七&lt;/a> 的去識別化是本模組的入場條件。&lt;/p>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 行為事件設計（事件命名規範 / 屬性設計 / funnel 定義）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Funnel analysis（使用者在哪一步流失）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Cohort analysis（不同族群的留存率差異）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Attribution（使用者從哪來、哪個廣告帶來轉換）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> A/B test 的統計基礎（假設檢定 / 樣本量 / 多重比較）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 推薦系統概論（collaborative filtering / content-based / 混合）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> RFM 分群（Recency / Frequency / Monetary 的工程實作）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 從 collector 資料做基礎 funnel 分析（自架方案能做到哪裡）&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>← &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安&lt;/a>：去識別化是入場條件&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>：event 類事件是行為分析的原料&lt;/li>
&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>：狀態轉換事件 → funnel 分析&lt;/li>
&lt;li>待建連結 → &lt;code>data-engineering/&lt;/code>（資料管線設計）&lt;/li>
&lt;li>待建連結 → &lt;code>statistics/&lt;/code>（A/B test 統計基礎）&lt;/li>
&lt;li>待建連結 → &lt;code>machine-learning/&lt;/code>（推薦系統架構）&lt;/li>
&lt;li>待建連結 → &lt;code>compliance/&lt;/code>（GDPR / CCPA / 個資法）&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「蒐集到的行為資料除了 debug，還能做什麼」。前提：<a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">模組七</a> 的去識別化是本模組的入場條件。</p>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> 行為事件設計（事件命名規範 / 屬性設計 / funnel 定義）</li>
<li><input checked="" disabled="" type="checkbox"> Funnel analysis（使用者在哪一步流失）</li>
<li><input checked="" disabled="" type="checkbox"> Cohort analysis（不同族群的留存率差異）</li>
<li><input checked="" disabled="" type="checkbox"> Attribution（使用者從哪來、哪個廣告帶來轉換）</li>
<li><input checked="" disabled="" type="checkbox"> A/B test 的統計基礎（假設檢定 / 樣本量 / 多重比較）</li>
<li><input checked="" disabled="" type="checkbox"> 推薦系統概論（collaborative filtering / content-based / 混合）</li>
<li><input checked="" disabled="" type="checkbox"> RFM 分群（Recency / Frequency / Monetary 的工程實作）</li>
<li><input checked="" disabled="" type="checkbox"> 從 collector 資料做基礎 funnel 分析（自架方案能做到哪裡）</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>← <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a>：去識別化是入場條件</li>
<li>← <a href="/blog/monitoring/01-mental-model/" data-link-title="模組一：監控心智模型" data-link-desc="四類事件（event / error / metric / lifecycle）的分類與收集策略">monitoring 模組一 心智模型</a>：event 類事件是行為分析的原料</li>
<li>← <a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態機</a>：狀態轉換事件 → funnel 分析</li>
<li>待建連結 → <code>data-engineering/</code>（資料管線設計）</li>
<li>待建連結 → <code>statistics/</code>（A/B test 統計基礎）</li>
<li>待建連結 → <code>machine-learning/</code>（推薦系統架構）</li>
<li>待建連結 → <code>compliance/</code>（GDPR / CCPA / 個資法）</li>
</ul>
]]></content:encoded></item><item><title>Developer Dashboard 設計</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/dashboard-developer/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/dashboard-developer/</guid><description>&lt;p>Developer dashboard 聚焦 error 追蹤和 debug。開發者的核心問題是「哪裡壞了、影響多少人、怎麼重現」。這個 dashboard 的所有視圖都圍繞 error 事件展開，其他三類事件（event / metric / lifecycle）作為 debug context 輔助。&lt;/p>
&lt;p>和 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/dashboard-devops/" data-link-title="DevOps Dashboard 設計" data-link-desc="Collector 和 SDK 是否健康 — 日常監控的服務狀態卡、吞吐量曲線、儲存用量，以及告警觸發後的排障視圖">DevOps dashboard&lt;/a> 的差異：DevOps 看「基礎設施是否健康」，Developer 看「程式碼是否正確」。Error 趨勢上升在 DevOps 眼中是「事件量異常」，在 Developer 眼中是「程式碼 bug」。&lt;/p>
&lt;h2 id="日常監控視圖">日常監控視圖&lt;/h2>
&lt;h3 id="error-摘要">Error 摘要&lt;/h3>
&lt;p>一個數字卡顯示最近 24 小時的 error 總數 + 和前一天的比較（上升 / 下降 / 持平）。旁邊標注「新 error」數量 — 過去 24 小時首次出現的 error name。&lt;/p>
&lt;p>新 error 的偵測邏輯：&lt;code>error.name&lt;/code> 在最近 24 小時的事件中存在、但在更早的事件中不存在。這是開發者最需要立即注意的 — 新版本引入的 bug 通常表現為「之前沒見過的 error name」。&lt;/p>
&lt;h3 id="error-列表">Error 列表&lt;/h3>
&lt;p>表格按 &lt;code>error.name&lt;/code> 分群，每行顯示：error 名稱、最近 24 小時出現次數、影響的 session 數、首次出現時間、最近出現時間。按出現次數降序排列。&lt;/p>
&lt;p>點擊某行進入 Error 詳情視圖。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">-- SQLite 層可用
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">as&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">DISTINCT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">session_id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">as&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">sessions&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">MIN&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">as&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">first_seen&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">MAX&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">as&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">last_seen&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">events&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">type&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;error&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">datetime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;now&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;-1 day&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DESC&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="error-趨勢">Error 趨勢&lt;/h3>
&lt;p>折線圖顯示過去 7 天每天的 error 數量。可選按 &lt;code>error.name&lt;/code> 過濾看單一 error 的趨勢，或看全部 error 的總趨勢。&lt;/p>
&lt;p>趨勢的判讀訊號：&lt;/p>
&lt;ul>
&lt;li>穩定持平 → 已知的 recurring error，排優先處理&lt;/li>
&lt;li>新版本部署後突然上升 → 該版本引入的 regression&lt;/li>
&lt;li>逐漸上升 → 累積性問題（記憶體洩漏、資源耗盡）&lt;/li>
&lt;/ul>
&lt;h3 id="版本健康">版本健康&lt;/h3>
&lt;p>按 &lt;code>source.version&lt;/code> 分群的 error 率比較。每個版本顯示：error 數量、error rate（error / 總事件比）、最常見的 error name。&lt;/p>
&lt;p>版本健康視圖幫助判斷「這個版本該不該 rollback」— 如果新版本的 error rate 顯著高於前一版，rollback 決策有數字依據。&lt;/p>
&lt;h2 id="debug-深入視圖">Debug 深入視圖&lt;/h2>
&lt;p>從日常監控的 Error 列表點擊某個 error 進入深入視圖。&lt;/p>
&lt;h3 id="error-詳情">Error 詳情&lt;/h3>
&lt;p>單個 error name 的完整資訊：&lt;/p>
&lt;ul>
&lt;li>Stack trace（最近一次出現的 &lt;code>error.data.stack_trace&lt;/code>）&lt;/li>
&lt;li>首次出現時間和總出現次數&lt;/li>
&lt;li>影響的 session 數和佔比&lt;/li>
&lt;li>按版本分佈（哪些版本有、哪些沒有）&lt;/li>
&lt;li>按平台分佈（iOS / Android / Web）&lt;/li>
&lt;li>最近 10 次出現的時間軸&lt;/li>
&lt;/ul>
&lt;h3 id="session-回放">Session 回放&lt;/h3>
&lt;p>選擇一個受影響的 session，顯示該 session 的完整事件序列。事件按時間排列，每筆事件顯示類型、名稱、時間、data 摘要。Error 事件用顯眼的樣式標記，讓開發者快速定位「error 發生前使用者做了什麼」。&lt;/p></description><content:encoded><![CDATA[<p>Developer dashboard 聚焦 error 追蹤和 debug。開發者的核心問題是「哪裡壞了、影響多少人、怎麼重現」。這個 dashboard 的所有視圖都圍繞 error 事件展開，其他三類事件（event / metric / lifecycle）作為 debug context 輔助。</p>
<p>和 <a href="/blog/monitoring/04-collector/dashboard-devops/" data-link-title="DevOps Dashboard 設計" data-link-desc="Collector 和 SDK 是否健康 — 日常監控的服務狀態卡、吞吐量曲線、儲存用量，以及告警觸發後的排障視圖">DevOps dashboard</a> 的差異：DevOps 看「基礎設施是否健康」，Developer 看「程式碼是否正確」。Error 趨勢上升在 DevOps 眼中是「事件量異常」，在 Developer 眼中是「程式碼 bug」。</p>
<h2 id="日常監控視圖">日常監控視圖</h2>
<h3 id="error-摘要">Error 摘要</h3>
<p>一個數字卡顯示最近 24 小時的 error 總數 + 和前一天的比較（上升 / 下降 / 持平）。旁邊標注「新 error」數量 — 過去 24 小時首次出現的 error name。</p>
<p>新 error 的偵測邏輯：<code>error.name</code> 在最近 24 小時的事件中存在、但在更早的事件中不存在。這是開發者最需要立即注意的 — 新版本引入的 bug 通常表現為「之前沒見過的 error name」。</p>
<h3 id="error-列表">Error 列表</h3>
<p>表格按 <code>error.name</code> 分群，每行顯示：error 名稱、最近 24 小時出現次數、影響的 session 數、首次出現時間、最近出現時間。按出現次數降序排列。</p>
<p>點擊某行進入 Error 詳情視圖。</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">-- SQLite 層可用
</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></span><span class="line"><span class="ln"> 3</span><span class="cl"><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">as</span><span class="w"> </span><span class="k">count</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="k">COUNT</span><span class="p">(</span><span class="k">DISTINCT</span><span class="w"> </span><span class="n">session_id</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">sessions</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="k">MIN</span><span class="p">(</span><span class="n">ts</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">first_seen</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">MAX</span><span class="p">(</span><span class="n">ts</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">last_seen</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">events</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="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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-1 day&#39;</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="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">name</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</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="k">count</span><span class="w"> </span><span class="k">DESC</span><span class="p">;</span></span></span></code></pre></div><h3 id="error-趨勢">Error 趨勢</h3>
<p>折線圖顯示過去 7 天每天的 error 數量。可選按 <code>error.name</code> 過濾看單一 error 的趨勢，或看全部 error 的總趨勢。</p>
<p>趨勢的判讀訊號：</p>
<ul>
<li>穩定持平 → 已知的 recurring error，排優先處理</li>
<li>新版本部署後突然上升 → 該版本引入的 regression</li>
<li>逐漸上升 → 累積性問題（記憶體洩漏、資源耗盡）</li>
</ul>
<h3 id="版本健康">版本健康</h3>
<p>按 <code>source.version</code> 分群的 error 率比較。每個版本顯示：error 數量、error rate（error / 總事件比）、最常見的 error name。</p>
<p>版本健康視圖幫助判斷「這個版本該不該 rollback」— 如果新版本的 error rate 顯著高於前一版，rollback 決策有數字依據。</p>
<h2 id="debug-深入視圖">Debug 深入視圖</h2>
<p>從日常監控的 Error 列表點擊某個 error 進入深入視圖。</p>
<h3 id="error-詳情">Error 詳情</h3>
<p>單個 error name 的完整資訊：</p>
<ul>
<li>Stack trace（最近一次出現的 <code>error.data.stack_trace</code>）</li>
<li>首次出現時間和總出現次數</li>
<li>影響的 session 數和佔比</li>
<li>按版本分佈（哪些版本有、哪些沒有）</li>
<li>按平台分佈（iOS / Android / Web）</li>
<li>最近 10 次出現的時間軸</li>
</ul>
<h3 id="session-回放">Session 回放</h3>
<p>選擇一個受影響的 session，顯示該 session 的完整事件序列。事件按時間排列，每筆事件顯示類型、名稱、時間、data 摘要。Error 事件用顯眼的樣式標記，讓開發者快速定位「error 發生前使用者做了什麼」。</p>
<p>Session 回放需要同一個 session_id 的所有四類事件。這是 event-enumeration-method 中「Debug — 最近操作」事件的核心消費場景。</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">-- SQLite 層可用
</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="k">type</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">ts</span><span class="p">,</span><span class="w"> </span><span class="k">data</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">session_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">?</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</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">ts</span><span class="p">;</span></span></span></code></pre></div><h3 id="平台分佈">平台分佈</h3>
<p>某個 error name 在不同平台和 OS 版本的分佈圖。幫助判斷「這個 error 是全平台問題、還是特定平台的 bug」。</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">-- SQLite 層可用
</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">json_extract</span><span class="p">(</span><span class="k">source</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;$.platform&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">platform</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">json_extract</span><span class="p">(</span><span class="k">source</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;$.os&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">os_version</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="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="k">count</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">events</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">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">AND</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">?</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">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">platform</span><span class="p">,</span><span class="w"> </span><span class="n">os_version</span><span class="p">;</span></span></span></code></pre></div><h2 id="事件覆蓋確認">事件覆蓋確認</h2>
<p>Developer dashboard 需要的所有事件在目前的事件設計中已完整覆蓋：</p>
<table>
  <thead>
      <tr>
          <th>視圖</th>
          <th>需要的事件</th>
          <th>對應的事件名稱</th>
          <th>覆蓋狀態</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Error 列表</td>
          <td>error GROUP BY name</td>
          <td><code>app.exception</code></td>
          <td>已覆蓋</td>
      </tr>
      <tr>
          <td>Error 趨勢</td>
          <td>error 時間序列</td>
          <td><code>app.exception</code></td>
          <td>已覆蓋</td>
      </tr>
      <tr>
          <td>版本比較</td>
          <td>error GROUP BY source.version</td>
          <td><code>app.exception</code> + source schema</td>
          <td>已覆蓋</td>
      </tr>
      <tr>
          <td>Session 回放</td>
          <td>同 session 全部事件</td>
          <td>四類事件 + session_id</td>
          <td>已覆蓋</td>
      </tr>
      <tr>
          <td>Stack trace</td>
          <td>error.data.stack_trace</td>
          <td><code>app.exception</code> data 欄位</td>
          <td>已覆蓋</td>
      </tr>
      <tr>
          <td>影響範圍</td>
          <td>COUNT DISTINCT session_id</td>
          <td>session_id schema</td>
          <td>已覆蓋</td>
      </tr>
      <tr>
          <td>平台分佈</td>
          <td>GROUP BY source.platform</td>
          <td>source schema</td>
          <td>已覆蓋</td>
      </tr>
  </tbody>
</table>
<h2 id="sqlite-層-vs-postgresql-層">SQLite 層 vs PostgreSQL 層</h2>
<p>Developer dashboard 的多數視圖在 SQLite 層就能運作 — 都是單表 GROUP BY 和 WHERE 過濾。</p>
<table>
  <thead>
      <tr>
          <th>視圖</th>
          <th>SQLite 層</th>
          <th>PostgreSQL 層新增</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Error 列表</td>
          <td>可用</td>
          <td></td>
      </tr>
      <tr>
          <td>Error 趨勢</td>
          <td>可用（7 天以內）</td>
          <td>長期趨勢（30 天以上）</td>
      </tr>
      <tr>
          <td>版本比較</td>
          <td>可用</td>
          <td></td>
      </tr>
      <tr>
          <td>Session 回放</td>
          <td>可用</td>
          <td></td>
      </tr>
      <tr>
          <td>平台分佈</td>
          <td>可用</td>
          <td></td>
      </tr>
      <tr>
          <td>Error 詳情</td>
          <td>可用</td>
          <td></td>
      </tr>
      <tr>
          <td>跨版本 P95 回應</td>
          <td>不可用</td>
          <td>percentile 函數</td>
      </tr>
  </tbody>
</table>
<p>開發者 debug 場景不需要 PostgreSQL — SQLite 層的查詢能力已涵蓋所有核心視圖。PostgreSQL 的需求來自效能指標的高級分析（P95 趨勢），但這屬於效能監控動機而非 debug 動機。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>DevOps dashboard 設計 → <a href="/blog/monitoring/04-collector/dashboard-devops/" data-link-title="DevOps Dashboard 設計" data-link-desc="Collector 和 SDK 是否健康 — 日常監控的服務狀態卡、吞吐量曲線、儲存用量，以及告警觸發後的排障視圖">DevOps Dashboard 設計</a></li>
<li>中台 dashboard 設計 → <a href="/blog/monitoring/04-collector/dashboard-business/" data-link-title="中台 Dashboard 設計" data-link-desc="使用者怎麼用、在哪流失、怎麼讓他們回來 — 營運和行銷的日常指標監控與深入分析視圖，全部需要 PostgreSQL 層">中台 Dashboard 設計</a></li>
<li>Error 事件的枚舉方法 → <a href="/blog/monitoring/01-mental-model/event-enumeration-method/" data-link-title="事件枚舉與補齊檢查" data-link-desc="從操作盤點系統性地推導出完整的事件清單 — 四類補齊檢查確保沒有遺漏、粒度判準確保每個事件只記一個事實">事件枚舉與補齊檢查</a></li>
<li>功能分層與 Backend 選擇 → <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a></li>
<li>Error fingerprint 分群取代 name 分群 → <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></li>
</ul>
]]></content:encoded></item><item><title>中台 Dashboard 設計</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/dashboard-business/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/dashboard-business/</guid><description>&lt;p>中台 dashboard 的消費者是營運單位和行銷單位，關心的是「使用者行為」和「商業指標」。這個 dashboard 和 &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> 的消費對象不同 — 開發者看 stack trace 和 error 分佈，營運看漏斗轉換和留存率。&lt;/p>
&lt;p>中台 dashboard 的所有深入分析視圖都需要 PostgreSQL 層（&lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇&lt;/a>），因為它們依賴跨 session 的 JOIN 和大規模聚合查詢。SQLite 層只能提供基礎的事件計數。&lt;/p>
&lt;h2 id="日常監控視圖">日常監控視圖&lt;/h2>
&lt;h3 id="dau--mau">DAU / MAU&lt;/h3>
&lt;p>每日活躍使用者數（DAU）和每月活躍使用者數（MAU）的趨勢折線圖。活躍使用者的定義是「該時間段內至少有一筆 &lt;code>session.start&lt;/code> 事件的唯一 session」。&lt;/p>
&lt;p>DAU / MAU 比值（粘性指數）是產品健康的基本訊號 — 比值越高代表使用者回訪越頻繁。一般 SaaS 產品的 DAU/MAU 在 10-20% 為正常範圍，社交類產品期望 50% 以上。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">-- PostgreSQL
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">date_trunc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;day&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">as&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">day&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">DISTINCT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">session_id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">as&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">dau&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">events&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">type&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;lifecycle&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;session.start&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">NOW&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;30 days&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">day&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">day&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="核心漏斗">核心漏斗&lt;/h3>
&lt;p>主要業務流程的每步轉換率。漏斗的步驟從 &lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/motivation-to-event-mapping/" data-link-title="動機驅動的事件設計" data-link-desc="Debug / 商業 / 資安 / 效能四個動機各自需要什麼事件 — 從「為什麼收」反推「收什麼」和「什麼階段啟用」">動機驅動的事件設計&lt;/a> 的商業動機段定義。&lt;/p>
&lt;p>日常視圖顯示最近 7 天的整體轉換率 — 營運人員每天看「昨天的漏斗有沒有異常」。轉換率突然下降是產品問題的早期訊號（UI 改版影響操作流程、第三方服務異常阻擋流程）。&lt;/p>
&lt;h3 id="功能使用排行">功能使用排行&lt;/h3>
&lt;p>按 &lt;code>event.name&lt;/code> 計數的排行榜。營運用它判斷「哪些功能有人用、哪些沒人用」— 功能投資的 ROI 判斷依據。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">-- SQLite 層可用（基礎計數）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">as&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">usage_count&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">events&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">type&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;event&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">datetime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;now&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;-7 days&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">usage_count&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DESC&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LIMIT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">20&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>功能使用排行是 SQLite 層就能提供的視圖 — 單表 GROUP BY。&lt;/p>
&lt;h2 id="分析深入視圖">分析深入視圖&lt;/h2>
&lt;p>日常視圖發現異常後，營運人員進入分析視圖深入探究。所有分析視圖都需要 PostgreSQL 層。&lt;/p>
&lt;h3 id="funnel-漏斗圖">Funnel 漏斗圖&lt;/h3>
&lt;p>互動式漏斗圖：選擇步驟 → 看每步轉換率 → 點擊某步看流失使用者的行為。&lt;/p>
&lt;p>Funnel 需要 session 級 JOIN — 「同一個 session 完成了步驟 1 到步驟 N 中的哪些步驟」。完整的 SQL 查詢見 &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/self-hosted-funnel/" data-link-title="從 collector 資料做基礎 funnel 分析" data-link-desc="SQLite 層能做什麼程度的 funnel、PostgreSQL 層提供什麼進階能力、JSONL 匯出後的臨時分析">從 collector 資料做基礎 funnel 分析&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>中台 dashboard 的消費者是營運單位和行銷單位，關心的是「使用者行為」和「商業指標」。這個 dashboard 和 <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> 的消費對象不同 — 開發者看 stack trace 和 error 分佈，營運看漏斗轉換和留存率。</p>
<p>中台 dashboard 的所有深入分析視圖都需要 PostgreSQL 層（<a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a>），因為它們依賴跨 session 的 JOIN 和大規模聚合查詢。SQLite 層只能提供基礎的事件計數。</p>
<h2 id="日常監控視圖">日常監控視圖</h2>
<h3 id="dau--mau">DAU / MAU</h3>
<p>每日活躍使用者數（DAU）和每月活躍使用者數（MAU）的趨勢折線圖。活躍使用者的定義是「該時間段內至少有一筆 <code>session.start</code> 事件的唯一 session」。</p>
<p>DAU / MAU 比值（粘性指數）是產品健康的基本訊號 — 比值越高代表使用者回訪越頻繁。一般 SaaS 產品的 DAU/MAU 在 10-20% 為正常範圍，社交類產品期望 50% 以上。</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">-- PostgreSQL
</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">date_trunc</span><span class="p">(</span><span class="s1">&#39;day&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">ts</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="k">day</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="k">COUNT</span><span class="p">(</span><span class="k">DISTINCT</span><span class="w"> </span><span class="n">session_id</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">dau</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><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;lifecycle&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;session.start&#39;</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">AND</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">NOW</span><span class="p">()</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="nb">INTERVAL</span><span class="w"> </span><span class="s1">&#39;30 days&#39;</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">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">day</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">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">day</span><span class="p">;</span></span></span></code></pre></div><h3 id="核心漏斗">核心漏斗</h3>
<p>主要業務流程的每步轉換率。漏斗的步驟從 <a href="/blog/monitoring/01-mental-model/motivation-to-event-mapping/" data-link-title="動機驅動的事件設計" data-link-desc="Debug / 商業 / 資安 / 效能四個動機各自需要什麼事件 — 從「為什麼收」反推「收什麼」和「什麼階段啟用」">動機驅動的事件設計</a> 的商業動機段定義。</p>
<p>日常視圖顯示最近 7 天的整體轉換率 — 營運人員每天看「昨天的漏斗有沒有異常」。轉換率突然下降是產品問題的早期訊號（UI 改版影響操作流程、第三方服務異常阻擋流程）。</p>
<h3 id="功能使用排行">功能使用排行</h3>
<p>按 <code>event.name</code> 計數的排行榜。營運用它判斷「哪些功能有人用、哪些沒人用」— 功能投資的 ROI 判斷依據。</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">-- SQLite 層可用（基礎計數）
</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">as</span><span class="w"> </span><span class="n">usage_count</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><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;event&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-7 days&#39;</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">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">name</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">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">usage_count</span><span class="w"> </span><span class="k">DESC</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">LIMIT</span><span class="w"> </span><span class="mi">20</span><span class="p">;</span></span></span></code></pre></div><p>功能使用排行是 SQLite 層就能提供的視圖 — 單表 GROUP BY。</p>
<h2 id="分析深入視圖">分析深入視圖</h2>
<p>日常視圖發現異常後，營運人員進入分析視圖深入探究。所有分析視圖都需要 PostgreSQL 層。</p>
<h3 id="funnel-漏斗圖">Funnel 漏斗圖</h3>
<p>互動式漏斗圖：選擇步驟 → 看每步轉換率 → 點擊某步看流失使用者的行為。</p>
<p>Funnel 需要 session 級 JOIN — 「同一個 session 完成了步驟 1 到步驟 N 中的哪些步驟」。完整的 SQL 查詢見 <a href="/blog/monitoring/08-business-analytics/self-hosted-funnel/" data-link-title="從 collector 資料做基礎 funnel 分析" data-link-desc="SQLite 層能做什麼程度的 funnel、PostgreSQL 層提供什麼進階能力、JSONL 匯出後的臨時分析">從 collector 資料做基礎 funnel 分析</a>。</p>
<h3 id="cohort-留存表">Cohort 留存表</h3>
<p>按「使用者首次出現日期」分群的留存率矩陣。行是 cohort（第 N 週註冊的使用者），列是「第 1/2/3/…週的回訪率」。</p>
<p>需要的事件：<code>user.first_seen</code>（cohort 分群依據）+ <code>session.start</code>（回訪判定）。</p>
<p><code>user.first_seen</code> 是 collector 端計算的衍生事件 — 當某個 session_id 或 user identifier 在系統中第一次出現時記錄。和 SDK 端送來的原始事件不同，它的產生者是 collector 的計算邏輯。</p>
<h3 id="ab-測試結果">A/B 測試結果</h3>
<p>實驗的 variant 間轉換率比較 + 統計顯著性指標（p-value、信賴區間）。</p>
<p>需要的事件：<code>experiment.{name}.assigned</code>（分組）+ <code>experiment.{name}.converted</code>（轉換）。這些事件在 <a href="/blog/monitoring/01-mental-model/motivation-to-event-mapping/" data-link-title="動機驅動的事件設計" data-link-desc="Debug / 商業 / 資安 / 效能四個動機各自需要什麼事件 — 從「為什麼收」反推「收什麼」和「什麼階段啟用」">動機驅動的事件設計</a> 的 A/B 測試段定義。統計分析的方法見 <a href="/blog/monitoring/08-business-analytics/ab-test-statistics/" data-link-title="A/B Test 的統計基礎" data-link-desc="假設檢定、樣本量計算、多重比較校正 — A/B test 不只是「比較兩個數字」，統計方法決定結論是否可靠">A/B test 的統計基礎</a>。</p>
<h3 id="rfm-分群散佈圖">RFM 分群散佈圖</h3>
<p>三維度（Recency / Frequency / Monetary）的使用者分群。每個使用者計算 R/F/M 分數，按分數分群後在散佈圖上顯示。</p>
<p>需要的事件：event 類的購買/使用事件 + lifecycle 的 session 事件。計算方法見 <a href="/blog/monitoring/08-business-analytics/rfm-segmentation/" data-link-title="RFM 分群" data-link-desc="Recency / Frequency / Monetary 三維度的使用者分群 — 從行為事件計算 RFM 分數、定義使用者群體、驅動差異化策略">RFM 分群</a>。</p>
<h3 id="通路歸因">通路歸因</h3>
<p>使用者從哪裡來（哪個廣告、哪個推薦連結、自然流量），每個通路帶來多少轉換。</p>
<p>需要的事件：<code>attribution.install_source</code>（SDK 首次啟動時從 referrer / UTM 參數 / deep link 取得安裝來源）+ <code>conversion.{type}</code>（轉換事件）。</p>
<p><code>attribution.install_source</code> 只在 SDK 首次啟動時送一次。來源資訊的取得方式依平台不同 — Web 從 URL 的 UTM 參數取、mobile app 從 deferred deep link 或 install referrer API 取。</p>
<h2 id="需要的缺口事件">需要的缺口事件</h2>
<p>中台 dashboard 暴露了三個目前事件表未覆蓋的事件：</p>
<table>
  <thead>
      <tr>
          <th>事件名稱</th>
          <th>類型</th>
          <th>產生者</th>
          <th>用途</th>
          <th>為什麼缺</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>user.first_seen</td>
          <td>lifecycle</td>
          <td>Collector 計算</td>
          <td>Cohort 分群依據</td>
          <td>原始事件設計聚焦 SDK 端，衍生計算事件不在設計範圍</td>
      </tr>
      <tr>
          <td>attribution.install_source</td>
          <td>event</td>
          <td>SDK 首次啟動</td>
          <td>通路歸因</td>
          <td>只在首次啟動送一次的事件沒有被操作盤點覆蓋</td>
      </tr>
      <tr>
          <td>session.active.count</td>
          <td>metric</td>
          <td>Collector 計算</td>
          <td>即時在線大屏</td>
          <td>即時統計是 collector 端的衍生 metric</td>
      </tr>
  </tbody>
</table>
<p>這三個事件的共同特徵：前兩個是「只發生一次」的事件（首次出現、首次安裝），第三個是 collector 端的即時計算結果。操作盤點和四類補齊檢查聚焦在「反覆發生的使用者操作」，容易遺漏「只發生一次」的生命週期轉折點和 collector 端的衍生計算。</p>
<h2 id="中台的權限隔離">中台的權限隔離</h2>
<p>營運和行銷人員看行為資料，但不需要也不應該看到 stack trace、raw error message、session 級別的原始事件明細。權限隔離在 collector 的查詢 API 層實作 — 不同的 API scope 回傳不同粒度的資料。</p>
<table>
  <thead>
      <tr>
          <th>Scope</th>
          <th>可見</th>
          <th>不可見</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>devops</td>
          <td>collector 健康 metric、SDK 狀態</td>
          <td>業務事件明細</td>
      </tr>
      <tr>
          <td>developer</td>
          <td>全部事件、stack trace、session 回放</td>
          <td>無限制</td>
      </tr>
      <tr>
          <td>business</td>
          <td>聚合統計（funnel/cohort/count）、匿名行為</td>
          <td>stack trace、error raw data、session 原始事件</td>
      </tr>
  </tbody>
</table>
<p>Scope 的實作可以是 API key 分級（不同 key 有不同 scope）、或 HTTP header 帶 role。Day-one 可以跳過（自用場景只有 developer 一個角色），tripwire 是「第一個非開發者要看 dashboard 時加入 scope 機制」。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>DevOps dashboard 設計 → <a href="/blog/monitoring/04-collector/dashboard-devops/" data-link-title="DevOps Dashboard 設計" data-link-desc="Collector 和 SDK 是否健康 — 日常監控的服務狀態卡、吞吐量曲線、儲存用量，以及告警觸發後的排障視圖">DevOps Dashboard 設計</a></li>
<li>Developer dashboard 設計 → <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>Funnel 分析的完整方法 → <a href="/blog/monitoring/08-business-analytics/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="使用者在哪一步流失 — 從事件序列計算每步轉換率、找出流失最嚴重的步驟、區分設計問題和技術問題">Funnel analysis</a></li>
<li>功能分層與 Backend 選擇 → <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a></li>
<li>去識別化是中台 dashboard 的入場條件 → <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">模組七 資安與隱私</a></li>
<li>畫面狀態矩陣定義了 funnel 步驟的操作來源 → <a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣</a></li>
</ul>
]]></content:encoded></item><item><title>Ingestion Scaling</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/ingestion-scaling/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/ingestion-scaling/</guid><description>&lt;p>Ingestion scaling 處理的是「大量事件同時湧入 collector 時怎麼辦」。這和 storage scaling（&lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">SQLite → PostgreSQL 的可插拔 backend&lt;/a>）是兩個獨立的擴展軸 — storage scaling 解決「查得動嗎」，ingestion scaling 解決「收得下嗎」。一個 collector 可能 storage 用 PostgreSQL（查詢能力足夠）但 ingestion 撐不住（HTTP 請求太多），反之亦然。&lt;/p>
&lt;h2 id="四層防線">四層防線&lt;/h2>
&lt;p>每一層在不同規模觸發，由近到遠依序啟用。前一層能擋住的流量不需要啟用後一層。本章的四層按防線位置劃分（SDK / Collector / 基礎設施兩層）。DevOps 的&lt;a href="https://tarrragon.github.io/blog/devops/07-burst-traffic/scale-tier-response/" data-link-title="規模分級應對表" data-link-desc="自用級 → 中型 → 大型 → 商業網站級的四級應對方案 — 每級的觸發條件、架構組成和成本">規模分級應對表&lt;/a>按 events/sec 量級劃分（Tier 1-4），兩者視角不同但覆蓋相同的擴展路徑。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>層&lt;/th>
 &lt;th>機制&lt;/th>
 &lt;th>在哪裡做&lt;/th>
 &lt;th>觸發條件&lt;/th>
 &lt;th>適用規模&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>一&lt;/td>
 &lt;td>SDK 端取樣 + 聚合前移&lt;/td>
 &lt;td>SDK&lt;/td>
 &lt;td>高頻事件超過合理粒度&lt;/td>
 &lt;td>所有規模&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>二&lt;/td>
 &lt;td>Collector 單機背壓 + rate limit&lt;/td>
 &lt;td>Collector&lt;/td>
 &lt;td>寫入 channel 接近滿載&lt;/td>
 &lt;td>自用 ~ 小型&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>三&lt;/td>
 &lt;td>水平擴展（多 collector + LB）&lt;/td>
 &lt;td>基礎設施&lt;/td>
 &lt;td>單機 CPU / 連線數飽和&lt;/td>
 &lt;td>中型 ~ 大型&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>四&lt;/td>
 &lt;td>Queue 解耦（Kafka / NATS）&lt;/td>
 &lt;td>基礎設施&lt;/td>
 &lt;td>突發流量超過 collector 群的即時處理能力&lt;/td>
 &lt;td>商業網站級&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="第一層sdk-端的流量控制">第一層：SDK 端的流量控制&lt;/h2>
&lt;p>流量控制的最有效位置是事件產生的源頭。SDK 端減少的事件量，後面每一層都不需要處理。&lt;/p>
&lt;h3 id="動態取樣">動態取樣&lt;/h3>
&lt;p>SDK 在收到 collector 的 HTTP 429（Too Many Requests）回應時，自動降低取樣率。恢復正常後逐步回升。&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">正常 → sampling 1.0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">收到 429 → sampling 降到 0.5
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">持續 429 → sampling 降到 0.1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">連續 10 次成功 → sampling 回升到 0.5
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">連續 30 次成功 → sampling 回到 1.0&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>動態取樣的控制邏輯在 SDK 端實作，不需要 collector 端額外支援 — 429 回應碼就是觸發訊號。和&lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/sensor-lifecycle-management/" data-link-title="感測器生命週期管理" data-link-desc="產品生命週期的五個階段各啟用什麼感測器 — feature flag 整合、取樣率動態調整、感測器開關的可觀察性">感測器生命週期管理&lt;/a>的靜態取樣率互補 — 靜態取樣在 config 中設定、動態取樣在執行期自動調整。&lt;/p>
&lt;h3 id="聚合前移">聚合前移&lt;/h3>
&lt;p>SDK 端累積一段時間的同名事件，送出摘要而非逐筆。適合 metric 類的高頻取樣。&lt;/p>
&lt;p>例：原本每 100ms 送一筆 &lt;code>render.frame_drop&lt;/code>，改成每 5 秒送一筆 &lt;code>render.frame_drop_summary&lt;/code>（帶 count + min + max + avg）。事件數從 50 筆/5s 降到 1 筆/5s。&lt;/p>
&lt;p>聚合前移犧牲事件粒度換取吞吐量。只適合「趨勢比每筆細節重要」的 metric 類事件。Error 和 lifecycle 事件不做聚合 — 每筆的 stack trace 和狀態轉換都有 debug 價值。&lt;/p></description><content:encoded><![CDATA[<p>Ingestion scaling 處理的是「大量事件同時湧入 collector 時怎麼辦」。這和 storage scaling（<a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">SQLite → PostgreSQL 的可插拔 backend</a>）是兩個獨立的擴展軸 — storage scaling 解決「查得動嗎」，ingestion scaling 解決「收得下嗎」。一個 collector 可能 storage 用 PostgreSQL（查詢能力足夠）但 ingestion 撐不住（HTTP 請求太多），反之亦然。</p>
<h2 id="四層防線">四層防線</h2>
<p>每一層在不同規模觸發，由近到遠依序啟用。前一層能擋住的流量不需要啟用後一層。本章的四層按防線位置劃分（SDK / Collector / 基礎設施兩層）。DevOps 的<a href="/blog/devops/07-burst-traffic/scale-tier-response/" data-link-title="規模分級應對表" data-link-desc="自用級 → 中型 → 大型 → 商業網站級的四級應對方案 — 每級的觸發條件、架構組成和成本">規模分級應對表</a>按 events/sec 量級劃分（Tier 1-4），兩者視角不同但覆蓋相同的擴展路徑。</p>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>機制</th>
          <th>在哪裡做</th>
          <th>觸發條件</th>
          <th>適用規模</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一</td>
          <td>SDK 端取樣 + 聚合前移</td>
          <td>SDK</td>
          <td>高頻事件超過合理粒度</td>
          <td>所有規模</td>
      </tr>
      <tr>
          <td>二</td>
          <td>Collector 單機背壓 + rate limit</td>
          <td>Collector</td>
          <td>寫入 channel 接近滿載</td>
          <td>自用 ~ 小型</td>
      </tr>
      <tr>
          <td>三</td>
          <td>水平擴展（多 collector + LB）</td>
          <td>基礎設施</td>
          <td>單機 CPU / 連線數飽和</td>
          <td>中型 ~ 大型</td>
      </tr>
      <tr>
          <td>四</td>
          <td>Queue 解耦（Kafka / NATS）</td>
          <td>基礎設施</td>
          <td>突發流量超過 collector 群的即時處理能力</td>
          <td>商業網站級</td>
      </tr>
  </tbody>
</table>
<h2 id="第一層sdk-端的流量控制">第一層：SDK 端的流量控制</h2>
<p>流量控制的最有效位置是事件產生的源頭。SDK 端減少的事件量，後面每一層都不需要處理。</p>
<h3 id="動態取樣">動態取樣</h3>
<p>SDK 在收到 collector 的 HTTP 429（Too Many Requests）回應時，自動降低取樣率。恢復正常後逐步回升。</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">正常 → sampling 1.0
</span></span><span class="line"><span class="ln">2</span><span class="cl">收到 429 → sampling 降到 0.5
</span></span><span class="line"><span class="ln">3</span><span class="cl">持續 429 → sampling 降到 0.1
</span></span><span class="line"><span class="ln">4</span><span class="cl">連續 10 次成功 → sampling 回升到 0.5
</span></span><span class="line"><span class="ln">5</span><span class="cl">連續 30 次成功 → sampling 回到 1.0</span></span></code></pre></div><p>動態取樣的控制邏輯在 SDK 端實作，不需要 collector 端額外支援 — 429 回應碼就是觸發訊號。和<a href="/blog/monitoring/03-sdk-design/sensor-lifecycle-management/" data-link-title="感測器生命週期管理" data-link-desc="產品生命週期的五個階段各啟用什麼感測器 — feature flag 整合、取樣率動態調整、感測器開關的可觀察性">感測器生命週期管理</a>的靜態取樣率互補 — 靜態取樣在 config 中設定、動態取樣在執行期自動調整。</p>
<h3 id="聚合前移">聚合前移</h3>
<p>SDK 端累積一段時間的同名事件，送出摘要而非逐筆。適合 metric 類的高頻取樣。</p>
<p>例：原本每 100ms 送一筆 <code>render.frame_drop</code>，改成每 5 秒送一筆 <code>render.frame_drop_summary</code>（帶 count + min + max + avg）。事件數從 50 筆/5s 降到 1 筆/5s。</p>
<p>聚合前移犧牲事件粒度換取吞吐量。只適合「趨勢比每筆細節重要」的 metric 類事件。Error 和 lifecycle 事件不做聚合 — 每筆的 stack trace 和狀態轉換都有 debug 價值。</p>
<h3 id="優先級丟棄">優先級丟棄</h3>
<p>SDK 的離線 buffer 滿時，按優先級丟棄。Error 的 debug 價值最高，最後丟。</p>
<table>
  <thead>
      <tr>
          <th>優先級</th>
          <th>事件類型</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>高</td>
          <td>error</td>
          <td>每筆都可能是需要修的 bug</td>
      </tr>
      <tr>
          <td>高</td>
          <td>lifecycle</td>
          <td>session 邊界和狀態轉換、影響 debug 和 cohort</td>
      </tr>
      <tr>
          <td>中</td>
          <td>metric</td>
          <td>丟幾筆不影響趨勢（聚合摘要仍然有效）</td>
      </tr>
      <tr>
          <td>低</td>
          <td>event</td>
          <td>行為事件在取樣後丟幾筆對 funnel 影響有限</td>
      </tr>
  </tbody>
</table>
<h2 id="第二層collector-單機的防護">第二層：Collector 單機的防護</h2>
<p>Collector 在自身能力範圍內保護自己不被壓垮。和 <a href="/blog/monitoring/04-collector/architecture/" data-link-title="Collector 架構" data-link-desc="HTTP endpoint → JSON Schema 驗證 → 儲存 → 查詢 → rule engine 的五段式處理鏈路">architecture.md 的並發寫入策略</a>直接相關 — 寫入 channel 是背壓的實作基礎。背壓和流量管控的通用概念見 <a href="/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">DevOps 流量管控</a>。</p>
<h3 id="寫入-channel-容量--背壓">寫入 channel 容量 + 背壓</h3>
<p>Single-writer goroutine pattern 的 Go channel 有固定容量（如 10,000）。Channel 滿時 HTTP handler 無法送入事件，此時回 429：</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">case</span> <span class="nx">writeCh</span> <span class="o">&lt;-</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">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusAccepted</span><span class="p">)</span> <span class="c1">// 202</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Retry-After&#34;</span><span class="p">,</span> <span class="s">&#34;5&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusTooManyRequests</span><span class="p">)</span> <span class="c1">// 429</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Channel 容量的設定依據：容量 × 每筆事件的記憶體大小 = 背壓 buffer 的記憶體上限。10,000 筆 × 每筆 ~1KB = ~10MB，對多數機器微不足道。</p>
<h3 id="per-sdk-rate-limiting">Per-SDK rate limiting</h3>
<p>按 source.app（或 API key，啟用認證後）限制每個 SDK 實例的請求速率。防止單一 SDK 的 bug（無限迴圈送事件）打爆 collector。</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="c1">// 每個 source.app 一個 rate limiter</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">limiter</span> <span class="o">:=</span> <span class="nx">rateLimiters</span><span class="p">.</span><span class="nf">GetOrCreate</span><span class="p">(</span><span class="nx">sourceApp</span><span class="p">,</span> <span class="nx">rate</span><span class="p">.</span><span class="nf">Limit</span><span class="p">(</span><span class="mi">100</span><span class="p">))</span> <span class="c1">// 100 events/sec</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">if</span> <span class="p">!</span><span class="nx">limiter</span><span class="p">.</span><span class="nf">Allow</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusTooManyRequests</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">return</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="error-快通道">Error 快通道</h3>
<p>Error 事件不經 rate limit — 它們的 debug 價值最高，且在正常情況下數量遠少於其他類型。Error storm（app 出 bug 導致大量 error）時，error 的量可能暴增，但這正是最需要記錄的時刻。</p>
<p>Error 快通道用獨立的 channel 或跳過 rate limiter 的 check。如果 error 量也超出承載，用第一層的 SDK 端優先級丟棄處理。</p>
<h2 id="第三層水平擴展">第三層：水平擴展</h2>
<p>單機的 CPU、記憶體或網路頻寬飽和時，水平擴展 — 多個 collector 實例分攤流量。水平擴展的通用模式見 <a href="/blog/devops/02-horizontal-scaling/" data-link-title="模組二：水平擴展" data-link-desc="一個實例不夠時怎麼加第二個 — stateless 設計、shared storage、session 處理的工程約束">DevOps 水平擴展</a>。</p>
<h3 id="前提已切換到-postgresql">前提：已切換到 PostgreSQL</h3>
<p>SQLite backend 不支援水平擴展。每個 collector 實例有各自的 SQLite 檔案，無法合併查詢。水平擴展的前提是所有 collector 寫入同一個 PostgreSQL。</p>
<h3 id="架構">架構</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">SDK ──→ Load Balancer (nginx / HAProxy)
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">             │
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        ┌────┴────┐
</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">   Collector A  Collector B
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        │         │
</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></span><span class="line"><span class="ln"> 9</span><span class="cl">        PostgreSQL
</span></span><span class="line"><span class="ln">10</span><span class="cl">             │
</span></span><span class="line"><span class="ln">11</span><span class="cl">             ▼
</span></span><span class="line"><span class="ln">12</span><span class="cl">         Dashboard</span></span></code></pre></div><p>Collector 實例是 stateless 的 — 不在記憶體保存查詢狀態，所有持久化資料在 PostgreSQL。任何 collector 接收的事件都能被任何 dashboard 查到。</p>
<p>Load balancer 用 round-robin 或 least-connections 分配。不需要 sticky session — collector 不保存 session 狀態。</p>
<h3 id="多機的-downsample-和-purge">多機的 Downsample 和 Purge</h3>
<p>Downsample 和 Purge job 只能由一個 collector 實例執行（避免重複處理）。用 PostgreSQL 的 advisory lock 或外部的 distributed lock 確保單一執行者。</p>
<h2 id="第四層queue-解耦">第四層：Queue 解耦</h2>
<p>突發流量超過 collector 群的即時處理能力時，在 collector 和 storage 之間插入 message queue 做緩衝。Queue 緩衝的通用概念見 <a href="/blog/devops/07-burst-traffic/" data-link-title="模組七：突發流量應對" data-link-desc="行銷活動或新聞曝光帶來 10x-100x 流量時怎麼撐 — 突發分類、降級策略、queue 緩衝、規模分級應對">DevOps 突發流量應對</a>，message queue 的選型見 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend 模組三 非同步與訊息佇列</a>。</p>
<h3 id="架構-1">架構</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">SDK ──→ Collector (ingestion only)
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">             │
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">             ▼
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        Queue (Kafka / NATS / Redis Streams)
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">             │
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        ┌────┴────┐
</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">    Worker A   Worker B
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        │         │
</span></span><span class="line"><span class="ln">10</span><span class="cl">        └────┬────┘
</span></span><span class="line"><span class="ln">11</span><span class="cl">             ▼
</span></span><span class="line"><span class="ln">12</span><span class="cl">        PostgreSQL</span></span></code></pre></div><p>Collector 的職責簡化為「接收 → 驗證 → 寫入 queue → 回 202」。寫入 queue 比寫入 DB 快得多（append-only、不需要索引更新），collector 的吞吐上限大幅提升。</p>
<p>Worker 從 queue 消費、寫入 PostgreSQL。Worker 按自己的速度處理 — 高峰時 queue 積壓，高峰過後 worker 消化積壓。Queue 的持久化保證事件不遺失。</p>
<h3 id="queue-的選擇">Queue 的選擇</h3>
<table>
  <thead>
      <tr>
          <th>Queue</th>
          <th>適合場景</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Kafka</td>
          <td>高吞吐（百萬 events/sec）、需要 replay</td>
          <td>運維重（ZooKeeper / KRaft）</td>
      </tr>
      <tr>
          <td>NATS JetStream</td>
          <td>輕量、Go 原生、足夠的持久化</td>
          <td>生態較小</td>
      </tr>
      <tr>
          <td>Redis Streams</td>
          <td>簡單、如果已有 Redis</td>
          <td>不是專門的 queue、持久化設定需注意</td>
      </tr>
  </tbody>
</table>
<p>自架監控工具的 queue 層級推薦 NATS JetStream — Go 原生 client、單 binary 部署、JetStream 提供持久化和 replay。</p>
<h3 id="觸發條件">觸發條件</h3>
<p>Queue 解耦的引入時機是「collector 群已水平擴展但仍無法處理突發流量」。如果日常流量 collector 群能處理，只有行銷活動 / 新聞曝光的短暫高峰需要 queue 緩衝，queue 的維護成本可能高於收益 — 考慮用第一層的動態取樣在源頭降量。</p>
<h2 id="功能分層整合">功能分層整合</h2>
<p>擴展 <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a> 的分層表，加入 ingestion 維度：</p>
<table>
  <thead>
      <tr>
          <th>功能層級</th>
          <th>Storage</th>
          <th>Ingestion</th>
          <th>適用規模</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SQLite 層</td>
          <td>SQLite embedded</td>
          <td>單 collector + 背壓</td>
          <td>自用 ~ 小型團隊</td>
      </tr>
      <tr>
          <td>PostgreSQL 層</td>
          <td>PostgreSQL</td>
          <td>多 collector + LB</td>
          <td>中型 ~ 大型</td>
      </tr>
      <tr>
          <td>Queue 層</td>
          <td>PostgreSQL</td>
          <td>Collector + Queue + Worker</td>
          <td>商業網站級</td>
      </tr>
  </tbody>
</table>
<p>每一層是前一層的超集 — Queue 層包含 PostgreSQL 層的所有查詢能力，加上 ingestion 的 queue 緩衝。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<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>Storage 端的擴展設計 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a></li>
<li>功能分層的定義 → <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a></li>
<li>背壓和流量管控的通用概念 → <a href="/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">DevOps 流量管控</a></li>
<li>水平擴展的通用模式 → <a href="/blog/devops/02-horizontal-scaling/" data-link-title="模組二：水平擴展" data-link-desc="一個實例不夠時怎麼加第二個 — stateless 設計、shared storage、session 處理的工程約束">DevOps 水平擴展</a></li>
<li>突發流量應對 → <a href="/blog/devops/07-burst-traffic/" data-link-title="模組七：突發流量應對" data-link-desc="行銷活動或新聞曝光帶來 10x-100x 流量時怎麼撐 — 突發分類、降級策略、queue 緩衝、規模分級應對">DevOps 突發流量</a></li>
<li>Message queue 選型 → <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend 模組三 非同步與訊息佇列</a></li>
<li>端到端資料完整性（資料損失地圖、完整性指標）→ <a href="/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性</a></li>
</ul>
]]></content:encoded></item><item><title>SQLite Backend 效能基準</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/sqlite-performance-baseline/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/sqlite-performance-baseline/</guid><description>&lt;p>SQLite Backend 的效能受三個因素影響：儲存裝置（SSD vs HDD vs SD card）、Go driver 選擇（modernc.org/sqlite pure Go vs mattn/go-sqlite3 CGO）、並發模型（WAL mode + single-writer）。本章根據 SQLite 的技術特性和業界基準推導預期效能範圍，並提供實測方法讓使用者在自己的環境驗證。所有數字是預期範圍而非實測值 — 實際效能依硬體和 workload 而定。&lt;/p>
&lt;h2 id="寫入吞吐">寫入吞吐&lt;/h2>
&lt;p>寫入吞吐決定 collector 每秒能消化多少事件。SQLite 的寫入效能主要受 fsync 頻率和 WAL checkpoint 影響。&lt;/p>
&lt;h3 id="單筆-insert">單筆 INSERT&lt;/h3>
&lt;p>每筆 INSERT 獨立一個 transaction 時，每次 commit 都會 fsync。WAL mode 的 fsync 成本比 journal mode 低（append-only），但仍是寫入的主要瓶頸。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>儲存裝置&lt;/th>
 &lt;th>單筆 INSERT 延遲&lt;/th>
 &lt;th>理論上限&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>NVMe SSD&lt;/td>
 &lt;td>10-30 μs&lt;/td>
 &lt;td>30,000-100,000 inserts/sec&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SATA SSD&lt;/td>
 &lt;td>30-50 μs&lt;/td>
 &lt;td>20,000-30,000 inserts/sec&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>HDD&lt;/td>
 &lt;td>50-200 μs&lt;/td>
 &lt;td>5,000-20,000 inserts/sec&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SD card&lt;/td>
 &lt;td>500-2000 μs&lt;/td>
 &lt;td>500-2,000 inserts/sec&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>modernc.org/sqlite（pure Go）的效能約為 CGO driver（mattn/go-sqlite3）的 60-80%。上表數字基於 CGO driver，pure Go 需打八折。Go HTTP handler 的開銷（JSON 解碼、schema 驗證、goroutine 調度）再扣 10-20%。&lt;/p>
&lt;h3 id="批次-insert">批次 INSERT&lt;/h3>
&lt;p>一個 transaction 包裹多筆 INSERT，只做一次 fsync。Collector 接收 SDK 的 flush batch（一個 HTTP request 帶一批事件）天然適合批次寫入。&lt;/p>
&lt;p>吞吐提升幅度和批次大小的關係：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>批次大小&lt;/th>
 &lt;th>相對單筆的吞吐提升&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>10 筆/tx&lt;/td>
 &lt;td>3-5x&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>100 筆/tx&lt;/td>
 &lt;td>5-10x&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>1000 筆/tx&lt;/td>
 &lt;td>8-15x&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>提升來自 fsync 次數從「每筆一次」降到「每批一次」。超過 100 筆/tx 後邊際收益遞減。&lt;/p>
&lt;h3 id="實際預期">實際預期&lt;/h3>
&lt;p>結合 pure Go driver、HTTP handler 開銷和批次寫入，不同環境下的預期吞吐：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>環境&lt;/th>
 &lt;th>單筆&lt;/th>
 &lt;th>批次（100/tx）&lt;/th>
 &lt;th>適合場景&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Mac M1/M2 NVMe + pure Go&lt;/td>
 &lt;td>~5,000/sec&lt;/td>
 &lt;td>~30,000/sec&lt;/td>
 &lt;td>開發機&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Linux VPS SATA SSD&lt;/td>
 &lt;td>~3,000/sec&lt;/td>
 &lt;td>~20,000/sec&lt;/td>
 &lt;td>小型部署&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Raspberry Pi 4 SD card&lt;/td>
 &lt;td>~200/sec&lt;/td>
 &lt;td>~1,000/sec&lt;/td>
 &lt;td>邊緣設備&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="和事件產生速率的對照">和事件產生速率的對照&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>場景&lt;/th>
 &lt;th>預估 events/sec&lt;/th>
 &lt;th>SQLite 批次能撐嗎&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>自用 1 個 app&lt;/td>
 &lt;td>&amp;lt; 10&lt;/td>
 &lt;td>遠超需求&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>小團隊 5 人各跑 1 個 app&lt;/td>
 &lt;td>&amp;lt; 50&lt;/td>
 &lt;td>綽綽有餘&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>10 SDK 同時 flush&lt;/td>
 &lt;td>100-1000 burst&lt;/td>
 &lt;td>批次 INSERT 撐得住&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>100+ 使用者持續活躍&lt;/td>
 &lt;td>500+ 持續&lt;/td>
 &lt;td>邊界 — 觀察 database is locked&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>burst 和持續的差異在於：burst 是短暫的高峰（flush batch 到達後數秒內消化完），持續是長時間的穩定高流量。SQLite 的 WAL mode 對 burst 容忍度高（write lock 等待時間短），對持續高流量容忍度有限（write lock 等待累積）。&lt;/p></description><content:encoded><![CDATA[<p>SQLite Backend 的效能受三個因素影響：儲存裝置（SSD vs HDD vs SD card）、Go driver 選擇（modernc.org/sqlite pure Go vs mattn/go-sqlite3 CGO）、並發模型（WAL mode + single-writer）。本章根據 SQLite 的技術特性和業界基準推導預期效能範圍，並提供實測方法讓使用者在自己的環境驗證。所有數字是預期範圍而非實測值 — 實際效能依硬體和 workload 而定。</p>
<h2 id="寫入吞吐">寫入吞吐</h2>
<p>寫入吞吐決定 collector 每秒能消化多少事件。SQLite 的寫入效能主要受 fsync 頻率和 WAL checkpoint 影響。</p>
<h3 id="單筆-insert">單筆 INSERT</h3>
<p>每筆 INSERT 獨立一個 transaction 時，每次 commit 都會 fsync。WAL mode 的 fsync 成本比 journal mode 低（append-only），但仍是寫入的主要瓶頸。</p>
<table>
  <thead>
      <tr>
          <th>儲存裝置</th>
          <th>單筆 INSERT 延遲</th>
          <th>理論上限</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>NVMe SSD</td>
          <td>10-30 μs</td>
          <td>30,000-100,000 inserts/sec</td>
      </tr>
      <tr>
          <td>SATA SSD</td>
          <td>30-50 μs</td>
          <td>20,000-30,000 inserts/sec</td>
      </tr>
      <tr>
          <td>HDD</td>
          <td>50-200 μs</td>
          <td>5,000-20,000 inserts/sec</td>
      </tr>
      <tr>
          <td>SD card</td>
          <td>500-2000 μs</td>
          <td>500-2,000 inserts/sec</td>
      </tr>
  </tbody>
</table>
<p>modernc.org/sqlite（pure Go）的效能約為 CGO driver（mattn/go-sqlite3）的 60-80%。上表數字基於 CGO driver，pure Go 需打八折。Go HTTP handler 的開銷（JSON 解碼、schema 驗證、goroutine 調度）再扣 10-20%。</p>
<h3 id="批次-insert">批次 INSERT</h3>
<p>一個 transaction 包裹多筆 INSERT，只做一次 fsync。Collector 接收 SDK 的 flush batch（一個 HTTP request 帶一批事件）天然適合批次寫入。</p>
<p>吞吐提升幅度和批次大小的關係：</p>
<table>
  <thead>
      <tr>
          <th>批次大小</th>
          <th>相對單筆的吞吐提升</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>10 筆/tx</td>
          <td>3-5x</td>
      </tr>
      <tr>
          <td>100 筆/tx</td>
          <td>5-10x</td>
      </tr>
      <tr>
          <td>1000 筆/tx</td>
          <td>8-15x</td>
      </tr>
  </tbody>
</table>
<p>提升來自 fsync 次數從「每筆一次」降到「每批一次」。超過 100 筆/tx 後邊際收益遞減。</p>
<h3 id="實際預期">實際預期</h3>
<p>結合 pure Go driver、HTTP handler 開銷和批次寫入，不同環境下的預期吞吐：</p>
<table>
  <thead>
      <tr>
          <th>環境</th>
          <th>單筆</th>
          <th>批次（100/tx）</th>
          <th>適合場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Mac M1/M2 NVMe + pure Go</td>
          <td>~5,000/sec</td>
          <td>~30,000/sec</td>
          <td>開發機</td>
      </tr>
      <tr>
          <td>Linux VPS SATA SSD</td>
          <td>~3,000/sec</td>
          <td>~20,000/sec</td>
          <td>小型部署</td>
      </tr>
      <tr>
          <td>Raspberry Pi 4 SD card</td>
          <td>~200/sec</td>
          <td>~1,000/sec</td>
          <td>邊緣設備</td>
      </tr>
  </tbody>
</table>
<h3 id="和事件產生速率的對照">和事件產生速率的對照</h3>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>預估 events/sec</th>
          <th>SQLite 批次能撐嗎</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自用 1 個 app</td>
          <td>&lt; 10</td>
          <td>遠超需求</td>
      </tr>
      <tr>
          <td>小團隊 5 人各跑 1 個 app</td>
          <td>&lt; 50</td>
          <td>綽綽有餘</td>
      </tr>
      <tr>
          <td>10 SDK 同時 flush</td>
          <td>100-1000 burst</td>
          <td>批次 INSERT 撐得住</td>
      </tr>
      <tr>
          <td>100+ 使用者持續活躍</td>
          <td>500+ 持續</td>
          <td>邊界 — 觀察 database is locked</td>
      </tr>
  </tbody>
</table>
<p>burst 和持續的差異在於：burst 是短暫的高峰（flush batch 到達後數秒內消化完），持續是長時間的穩定高流量。SQLite 的 WAL mode 對 burst 容忍度高（write lock 等待時間短），對持續高流量容忍度有限（write lock 等待累積）。</p>
<h2 id="查詢延遲">查詢延遲</h2>
<p>查詢延遲決定 dashboard 的刷新體驗。SQLite 的查詢效能取決於索引覆蓋和掃描行數。</p>
<h3 id="有索引的查詢">有索引的查詢</h3>
<p>建議的索引（見 <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a> 的建議索引段）覆蓋 dashboard 的核心查詢模式。有索引時的預期延遲：</p>
<table>
  <thead>
      <tr>
          <th>查詢模式</th>
          <th>10 萬筆</th>
          <th>50 萬筆</th>
          <th>100 萬筆</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>等值查詢（WHERE session_id = ?）</td>
          <td>&lt; 1ms</td>
          <td>&lt; 1ms</td>
          <td>&lt; 1ms</td>
      </tr>
      <tr>
          <td>範圍查詢（WHERE ts BETWEEN ? AND ?）</td>
          <td>&lt; 10ms</td>
          <td>10-50ms</td>
          <td>50-100ms</td>
      </tr>
      <tr>
          <td>GROUP BY name</td>
          <td>10-50ms</td>
          <td>50-200ms</td>
          <td>200-500ms</td>
      </tr>
      <tr>
          <td>COUNT DISTINCT session_id</td>
          <td>50-100ms</td>
          <td>200-500ms</td>
          <td>500ms-1s</td>
      </tr>
      <tr>
          <td>JOIN + window function</td>
          <td>100ms-1s</td>
          <td>1-3s</td>
          <td>3-10s</td>
      </tr>
  </tbody>
</table>
<h3 id="無索引的查詢">無索引的查詢</h3>
<p>無索引時 SQLite 做全表掃描。掃描速度約 50-100 MB/sec（SSD）、10-30 MB/sec（HDD）。</p>
<table>
  <thead>
      <tr>
          <th>資料量</th>
          <th>預估大小</th>
          <th>SSD 全掃延遲</th>
          <th>HDD 全掃延遲</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>10 萬筆</td>
          <td>~40 MB</td>
          <td>200-500ms</td>
          <td>1-3s</td>
      </tr>
      <tr>
          <td>100 萬筆</td>
          <td>~400 MB</td>
          <td>2-5s</td>
          <td>10-30s</td>
      </tr>
      <tr>
          <td>300 萬筆</td>
          <td>~1.2 GB</td>
          <td>5-15s</td>
          <td>30-90s</td>
      </tr>
  </tbody>
</table>
<p>超過 100 萬筆無索引查詢會超出 dashboard 可接受的刷新延遲 — 這是 day-one 就建索引的理由。</p>
<h3 id="dashboard-刷新頻率-vs-查詢延遲">Dashboard 刷新頻率 vs 查詢延遲</h3>
<p>Dashboard 的每個視圖有不同的刷新間隔和可接受延遲。查詢延遲超過可接受值時，dashboard 體驗變差（等待轉圈、資料過時）。</p>
<table>
  <thead>
      <tr>
          <th>Dashboard 視圖</th>
          <th>刷新間隔</th>
          <th>可接受延遲</th>
          <th>10 萬筆有索引</th>
          <th>100 萬筆有索引</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即時狀態卡</td>
          <td>1-5 秒</td>
          <td>&lt; 100ms</td>
          <td>滿足</td>
          <td>滿足</td>
      </tr>
      <tr>
          <td>Error 列表</td>
          <td>5-10 秒</td>
          <td>&lt; 500ms</td>
          <td>滿足</td>
          <td>滿足</td>
      </tr>
      <tr>
          <td>趨勢圖（最近 24h）</td>
          <td>30 秒</td>
          <td>&lt; 1s</td>
          <td>滿足</td>
          <td>邊界</td>
      </tr>
      <tr>
          <td>長期聚合（最近 30 天）</td>
          <td>5 分鐘</td>
          <td>&lt; 3s</td>
          <td>滿足</td>
          <td>需要預聚合</td>
      </tr>
  </tbody>
</table>
<p>「需要預聚合」代表原始事件的聚合查詢超過可接受延遲，應該依賴分層保留策略中的 hourly_summary / daily_summary 表（見 <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a> 的分層保留段）。</p>
<h2 id="資源消耗">資源消耗</h2>
<h3 id="記憶體">記憶體</h3>
<table>
  <thead>
      <tr>
          <th>元件</th>
          <th>佔用</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Go HTTP server</td>
          <td>20-50 MB</td>
          <td>基礎開銷</td>
      </tr>
      <tr>
          <td>SQLite page cache</td>
          <td>2 MB（預設）</td>
          <td><code>PRAGMA cache_size</code> 可調</td>
      </tr>
      <tr>
          <td>寫入 buffer（channel）</td>
          <td>1-10 MB</td>
          <td>取決於 channel 容量和事件大小</td>
      </tr>
      <tr>
          <td>查詢結果暫存</td>
          <td>和結果集成正比</td>
          <td>GROUP BY 10 萬筆 ~10 MB</td>
      </tr>
      <tr>
          <td><strong>Collector 整體</strong></td>
          <td><strong>50-100 MB</strong></td>
          <td>自用場景</td>
      </tr>
  </tbody>
</table>
<p>Raspberry Pi（1 GB RAM）上建議把 page cache 調小（<code>PRAGMA cache_size = -512</code> = 512 KB），避免大結果集查詢（加 LIMIT），dashboard 刷新頻率降低。</p>
<h3 id="cpu">CPU</h3>
<table>
  <thead>
      <tr>
          <th>操作</th>
          <th>CPU 使用</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>INSERT（寫入）</td>
          <td>可忽略</td>
          <td>I/O bound，CPU 不是瓶頸</td>
      </tr>
      <tr>
          <td>SELECT（查詢）</td>
          <td>和掃描行數正比</td>
          <td>有索引時可忽略</td>
      </tr>
      <tr>
          <td>Downsample（每小時）</td>
          <td>短暫 spike &lt; 1s</td>
          <td>處理最近一小時的事件</td>
      </tr>
      <tr>
          <td>Purge（每天）</td>
          <td>短暫 spike 1-3s</td>
          <td>分批 DELETE</td>
      </tr>
      <tr>
          <td><strong>整體</strong></td>
          <td><strong>&lt; 5%</strong></td>
          <td>自用場景</td>
      </tr>
  </tbody>
</table>
<h3 id="磁碟">磁碟</h3>
<table>
  <thead>
      <tr>
          <th>日事件量</th>
          <th>原始資料/天</th>
          <th>原始資料/月</th>
          <th>含索引/月</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1,000（極低）</td>
          <td>0.3-0.5 MB</td>
          <td>9-15 MB</td>
          <td>11-18 MB</td>
      </tr>
      <tr>
          <td>10,000（自用）</td>
          <td>3-5 MB</td>
          <td>90-150 MB</td>
          <td>110-180 MB</td>
      </tr>
      <tr>
          <td>100,000（小團隊）</td>
          <td>30-50 MB</td>
          <td>0.9-1.5 GB</td>
          <td>1.1-1.8 GB</td>
      </tr>
  </tbody>
</table>
<p>WAL 檔案通常 &lt; 10 MB（auto-checkpoint 在 WAL 達到 1000 pages 時觸發）。分層保留策略下，原始事件只保留 7 天，長期佔用由聚合摘要表決定（遠小於原始事件）。</p>
<h2 id="邊緣設備場景">邊緣設備場景</h2>
<p>Raspberry Pi、低配 VPS（1 核 / 1 GB RAM）、甚至 NAS 上跑 collector 時的特殊考量：</p>
<p><strong>SD card 的隨機寫入</strong>：SD card 的隨機寫入 IOPS 極低（100-500 IOPS），WAL mode 的 checkpoint（把 WAL 內容合併回主資料庫檔案）可能卡住 1-5 秒。期間新的寫入等待 checkpoint 完成。建議調高 <code>wal_autocheckpoint</code> 的閾值（如 5000 pages），讓 checkpoint 頻率降低但每次時間更長 — 在非活躍時段（凌晨）手動觸發 <code>PRAGMA wal_checkpoint(TRUNCATE)</code>。</p>
<p><strong>1 GB RAM</strong>：cache_size 調小（512 KB）、避免 <code>SELECT *</code> 不帶 LIMIT、GROUP BY 的結果集用 HAVING 條件過濾減少暫存。Dashboard 的長期聚合直接查 hourly_summary 表而非原始事件。</p>
<p><strong>ARM CPU</strong>：pure Go SQLite driver（modernc.org/sqlite）在 ARM 上的效能差距可能比 x86 更大（pure Go 的 C-to-Go 翻譯在 ARM 的指令最佳化較少）。實測確認。</p>
<p><strong>建議配置</strong>：邊緣設備上 collector 的 dashboard 刷新頻率從預設值降低（即時狀態卡 5 秒 → 30 秒，趨勢圖 30 秒 → 5 分鐘），降採樣 job 頻率從每小時改為每 6 小時。</p>
<h2 id="實測方法指引">實測方法指引</h2>
<p>教學的預期數字是推導值，實際效能取決於使用者的硬體和 workload。Collector 提供內建的 benchmark 命令讓使用者在自己的環境實測。</p>
<h3 id="寫入-benchmark">寫入 benchmark</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 單筆寫入：10000 筆，每筆獨立 transaction</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">./collector benchmark write --events<span class="o">=</span><span class="m">10000</span> --batch<span class="o">=</span><span class="m">1</span> --storage<span class="o">=</span>sqlite
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 批次寫入：10000 筆，每 100 筆一個 transaction</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">./collector benchmark write --events<span class="o">=</span><span class="m">10000</span> --batch<span class="o">=</span><span class="m">100</span> --storage<span class="o">=</span>sqlite</span></span></code></pre></div><p>輸出：total duration、events/sec、p50/p95/p99 latency per event。</p>
<h3 id="查詢-benchmark">查詢 benchmark</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 先灌入測試資料</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">./collector benchmark seed --events<span class="o">=</span><span class="m">100000</span> --storage<span class="o">=</span>sqlite
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 跑查詢 benchmark</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">./collector benchmark query --type<span class="o">=</span>error --group-by<span class="o">=</span>name --storage<span class="o">=</span>sqlite
</span></span><span class="line"><span class="ln">6</span><span class="cl">./collector benchmark query --session-id<span class="o">=</span>random --storage<span class="o">=</span>sqlite</span></span></code></pre></div><p>輸出：query duration、rows scanned、rows returned。</p>
<h3 id="production-觀察指標">Production 觀察指標</h3>
<p>部署後用 DevOps dashboard（見 <a href="/blog/monitoring/04-collector/dashboard-devops/" data-link-title="DevOps Dashboard 設計" data-link-desc="Collector 和 SDK 是否健康 — 日常監控的服務狀態卡、吞吐量曲線、儲存用量，以及告警觸發後的排障視圖">DevOps Dashboard 設計</a>）觀察 collector 自身的效能 metric：</p>
<ul>
<li><code>collector.storage.write_duration_ms</code>：每次寫入的延遲。P95 超過 100ms 是瓶頸訊號。</li>
<li><code>collector.storage.query_duration_ms</code>：每次查詢的延遲。P95 超過 dashboard 刷新間隔是瓶頸訊號。</li>
<li><code>collector.storage.db_size_bytes</code>：資料庫大小。接近磁碟可用空間的 80% 時觸發 purge 或擴容。</li>
<li><code>collector.storage.wal_size_bytes</code>：WAL 檔案大小。持續 &gt; 50 MB 代表 checkpoint 跟不上寫入速度。</li>
</ul>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>切換到 PostgreSQL 的觸發條件 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a></li>
<li>SQLite 和 PostgreSQL 的功能分層 → <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a></li>
<li>Ingestion 端的擴展設計 → <a href="/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling</a></li>
</ul>
]]></content:encoded></item><item><title>無 SSH 環境的監控與告警</title><link>https://tarrragon.github.io/blog/infra/takeover/legacy-external-monitoring/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/legacy-external-monitoring/</guid><description>&lt;p>無 SSH 的環境通常不允許安裝監控 agent（Datadog agent、New Relic APM daemon 都需要 daemon 常駐或 root 權限），伺服器的內部指標（CPU、記憶體、磁碟）只能從主機商的控制面板看到靜態數值，沒有告警機制。這種環境的監控策略是從外部觀測——用 HTTP check 確認服務存活、用不需要 agent 的錯誤追蹤服務捕捉例外、用定期量測建立效能基線。每一層都不依賴 server 端安裝任何東西。&lt;/p>
&lt;h2 id="可用性監控外部-http-check">可用性監控（外部 HTTP check）&lt;/h2>
&lt;p>外部 HTTP check 的運作方式是從第三方伺服器定期對目標 URL 發 HTTP 請求，驗證回應狀態碼、回應時間、以及頁面內容是否包含預期的文字。服務掛了或回應異常時觸發告警。&lt;/p>
&lt;h3 id="工具選型">工具選型&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工具&lt;/th>
 &lt;th>免費方案&lt;/th>
 &lt;th>檢查間隔&lt;/th>
 &lt;th>特色&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>UptimeRobot&lt;/td>
 &lt;td>50 個 monitor&lt;/td>
 &lt;td>5 分鐘&lt;/td>
 &lt;td>設定簡單、API 可整合&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Better Stack&lt;/td>
 &lt;td>10 個 monitor&lt;/td>
 &lt;td>3 分鐘&lt;/td>
 &lt;td>含 incident 管理與 status page&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Pingdom&lt;/td>
 &lt;td>1 個 monitor（試用）&lt;/td>
 &lt;td>1 分鐘&lt;/td>
 &lt;td>Synthetic monitoring、付費功能完整&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>UptimeRobot 的免費方案對多數無 SSH 環境的站台足夠——50 個 monitor 可以覆蓋一個站台的主要入口。&lt;/p>
&lt;h3 id="該監控哪些-url">該監控哪些 URL&lt;/h3>
&lt;p>選監控目標的判準是「這個 URL 掛了代表哪一層出問題」：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>URL&lt;/th>
 &lt;th>驗證的層次&lt;/th>
 &lt;th>掛了代表什麼&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>首頁&lt;/td>
 &lt;td>web server 存活&lt;/td>
 &lt;td>Apache/Nginx 或 PHP 本身掛了&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>登入頁&lt;/td>
 &lt;td>應用框架正常運作&lt;/td>
 &lt;td>PHP session 或框架初始化失敗&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>一個資料庫相依的頁面&lt;/td>
 &lt;td>DB 連線存活&lt;/td>
 &lt;td>MySQL 掛了或連線數滿了&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>金流 callback URL&lt;/td>
 &lt;td>第三方服務可達&lt;/td>
 &lt;td>付款回調會失敗、訂單狀態卡住&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每個 monitor 設兩層閾值：回應時間 &amp;gt;3 秒為警告（效能劣化的早期訊號）、&amp;gt;10 秒或非 200 狀態碼為嚴重（服務已不可用）。&lt;/p>
&lt;h3 id="告警通道">告警通道&lt;/h3>
&lt;p>免費方案通常支援 email 與 webhook（可串 Slack）。付費方案加 SMS 和電話。接手初期用 email + Slack 即可，等確認告警不會誤報後再決定要不要升級到 SMS。頻繁誤報會讓團隊學會忽略通知——閾值要設在「真的有問題才響」的水位。&lt;/p>
&lt;h2 id="錯誤追蹤不需要-server-agent">錯誤追蹤（不需要 server agent）&lt;/h2>
&lt;p>PHP 的錯誤追蹤在無 SSH 環境有兩條路徑：server 端用 PHP 內建的 error_log、client 端用不需要安裝的 SaaS 服務。&lt;/p>
&lt;h3 id="php-error_logserver-端不需-ssh">PHP error_log（server 端、不需 SSH）&lt;/h3>
&lt;p>PHP 可以把錯誤寫進檔案，設定方式是在 &lt;code>.htaccess&lt;/code> 或 &lt;code>php.ini&lt;/code>（如果主機允許）加入：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-apache" data-lang="apache">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c"># .htaccess — 啟用錯誤記錄、關閉畫面顯示&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">php_flag&lt;/span> display_errors &lt;span class="k">off&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nb">php_flag&lt;/span> log_errors &lt;span class="k">on&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="nb">php_value&lt;/span> error_log &lt;span class="sx">/home/user/logs/php_errors.log&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>error_log&lt;/code> 的路徑要指向 web root 之外的目錄，避免錯誤訊息被外部存取。設定後透過 FTP 定期下載這個檔案、用 grep 篩選嚴重等級：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 篩選 Fatal 和 Warning（過濾掉 Notice / Deprecated）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">grep -E &lt;span class="s2">&amp;#34;Fatal|Warning&amp;#34;&lt;/span> php_errors.log &lt;span class="p">|&lt;/span> tail -50&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="sentryphp--javascript不需-server-agent">Sentry（PHP + JavaScript、不需 server agent）&lt;/h3>
&lt;p>Sentry 的 PHP SDK 不需要系統層 agent，只需要在應用程式碼裡初始化：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">composer require sentry/sentry&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-php" data-lang="php">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 在應用程式進入點（如 index.php 最前面）加入
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">\Sentry\init&lt;/span>&lt;span class="p">([&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="s1">&amp;#39;dsn&amp;#39;&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="s1">&amp;#39;https://examplekey@o0.ingest.sentry.io/0&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="s1">&amp;#39;traces_sample_rate&amp;#39;&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="mf">0.1&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">]);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式碼會在 PHP 拋出未捕捉的例外或觸發 error 時，把錯誤資訊（stack trace、request context、使用者資訊）透過 HTTP 送到 Sentry 的 SaaS 平台。免費方案每月 5,000 個事件，對流量不大的流量不大的站台通常足夠。&lt;/p></description><content:encoded><![CDATA[<p>無 SSH 的環境通常不允許安裝監控 agent（Datadog agent、New Relic APM daemon 都需要 daemon 常駐或 root 權限），伺服器的內部指標（CPU、記憶體、磁碟）只能從主機商的控制面板看到靜態數值，沒有告警機制。這種環境的監控策略是從外部觀測——用 HTTP check 確認服務存活、用不需要 agent 的錯誤追蹤服務捕捉例外、用定期量測建立效能基線。每一層都不依賴 server 端安裝任何東西。</p>
<h2 id="可用性監控外部-http-check">可用性監控（外部 HTTP check）</h2>
<p>外部 HTTP check 的運作方式是從第三方伺服器定期對目標 URL 發 HTTP 請求，驗證回應狀態碼、回應時間、以及頁面內容是否包含預期的文字。服務掛了或回應異常時觸發告警。</p>
<h3 id="工具選型">工具選型</h3>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>免費方案</th>
          <th>檢查間隔</th>
          <th>特色</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>UptimeRobot</td>
          <td>50 個 monitor</td>
          <td>5 分鐘</td>
          <td>設定簡單、API 可整合</td>
      </tr>
      <tr>
          <td>Better Stack</td>
          <td>10 個 monitor</td>
          <td>3 分鐘</td>
          <td>含 incident 管理與 status page</td>
      </tr>
      <tr>
          <td>Pingdom</td>
          <td>1 個 monitor（試用）</td>
          <td>1 分鐘</td>
          <td>Synthetic monitoring、付費功能完整</td>
      </tr>
  </tbody>
</table>
<p>UptimeRobot 的免費方案對多數無 SSH 環境的站台足夠——50 個 monitor 可以覆蓋一個站台的主要入口。</p>
<h3 id="該監控哪些-url">該監控哪些 URL</h3>
<p>選監控目標的判準是「這個 URL 掛了代表哪一層出問題」：</p>
<table>
  <thead>
      <tr>
          <th>URL</th>
          <th>驗證的層次</th>
          <th>掛了代表什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>首頁</td>
          <td>web server 存活</td>
          <td>Apache/Nginx 或 PHP 本身掛了</td>
      </tr>
      <tr>
          <td>登入頁</td>
          <td>應用框架正常運作</td>
          <td>PHP session 或框架初始化失敗</td>
      </tr>
      <tr>
          <td>一個資料庫相依的頁面</td>
          <td>DB 連線存活</td>
          <td>MySQL 掛了或連線數滿了</td>
      </tr>
      <tr>
          <td>金流 callback URL</td>
          <td>第三方服務可達</td>
          <td>付款回調會失敗、訂單狀態卡住</td>
      </tr>
  </tbody>
</table>
<p>每個 monitor 設兩層閾值：回應時間 &gt;3 秒為警告（效能劣化的早期訊號）、&gt;10 秒或非 200 狀態碼為嚴重（服務已不可用）。</p>
<h3 id="告警通道">告警通道</h3>
<p>免費方案通常支援 email 與 webhook（可串 Slack）。付費方案加 SMS 和電話。接手初期用 email + Slack 即可，等確認告警不會誤報後再決定要不要升級到 SMS。頻繁誤報會讓團隊學會忽略通知——閾值要設在「真的有問題才響」的水位。</p>
<h2 id="錯誤追蹤不需要-server-agent">錯誤追蹤（不需要 server agent）</h2>
<p>PHP 的錯誤追蹤在無 SSH 環境有兩條路徑：server 端用 PHP 內建的 error_log、client 端用不需要安裝的 SaaS 服務。</p>
<h3 id="php-error_logserver-端不需-ssh">PHP error_log（server 端、不需 SSH）</h3>
<p>PHP 可以把錯誤寫進檔案，設定方式是在 <code>.htaccess</code> 或 <code>php.ini</code>（如果主機允許）加入：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-apache" data-lang="apache"><span class="line"><span class="ln">1</span><span class="cl"><span class="c"># .htaccess — 啟用錯誤記錄、關閉畫面顯示</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">php_flag</span> display_errors <span class="k">off</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nb">php_flag</span> log_errors <span class="k">on</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nb">php_value</span> error_log <span class="sx">/home/user/logs/php_errors.log</span></span></span></code></pre></div><p><code>error_log</code> 的路徑要指向 web root 之外的目錄，避免錯誤訊息被外部存取。設定後透過 FTP 定期下載這個檔案、用 grep 篩選嚴重等級：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 篩選 Fatal 和 Warning（過濾掉 Notice / Deprecated）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">grep -E <span class="s2">&#34;Fatal|Warning&#34;</span> php_errors.log <span class="p">|</span> tail -50</span></span></code></pre></div><h3 id="sentryphp--javascript不需-server-agent">Sentry（PHP + JavaScript、不需 server agent）</h3>
<p>Sentry 的 PHP SDK 不需要系統層 agent，只需要在應用程式碼裡初始化：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">composer require sentry/sentry</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 在應用程式進入點（如 index.php 最前面）加入
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">\Sentry\init</span><span class="p">([</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s1">&#39;dsn&#39;</span> <span class="o">=&gt;</span> <span class="s1">&#39;https://examplekey@o0.ingest.sentry.io/0&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s1">&#39;traces_sample_rate&#39;</span> <span class="o">=&gt;</span> <span class="mf">0.1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">]);</span></span></span></code></pre></div><p>這段程式碼會在 PHP 拋出未捕捉的例外或觸發 error 時，把錯誤資訊（stack trace、request context、使用者資訊）透過 HTTP 送到 Sentry 的 SaaS 平台。免費方案每月 5,000 個事件，對流量不大的流量不大的站台通常足夠。</p>
<p>前端的 JavaScript 錯誤追蹤更簡單——在 HTML 的 <code>&lt;head&gt;</code> 加一行 Sentry 的 CDN script，不需要修改 server 設定：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">script</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="na">src</span><span class="o">=</span><span class="s">&#34;https://browser.sentry-cdn.com/8.x/bundle.tracing.min.js&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="na">crossorigin</span><span class="o">=</span><span class="s">&#34;anonymous&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">&gt;&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">&lt;</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nx">Sentry</span><span class="p">.</span><span class="nx">init</span><span class="p">({</span> <span class="nx">dsn</span><span class="o">:</span> <span class="s2">&#34;https://examplekey@o0.ingest.sentry.io/0&#34;</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span></span></span></code></pre></div><p>JavaScript SDK 捕捉的是瀏覽器端的錯誤——DOM 操作失敗、AJAX 請求異常、未處理的 Promise rejection。跟 PHP 端的 SDK 各抓不同層的問題。</p>
<h3 id="error_log-vs-sentry-的分工">error_log vs Sentry 的分工</h3>
<p>error_log 是 server 端的文字紀錄，需要手動下載和篩選；Sentry 有搜尋、聚合、告警和 stack trace 視覺化。兩者互補：error_log 保留完整紀錄作為備份、Sentry 提供可操作的告警和分析介面。error_log 在 PHP 嚴重到 Sentry SDK 自己也掛掉的情況下仍然有紀錄。</p>
<h2 id="效能基線">效能基線</h2>
<p>效能基線的責任是回答「正常狀態下回應時間是多少」，讓異常浮現時有比對的參考。沒有基線時，回應時間從 200ms 劣化到 2 秒、但因為「好像一直都這麼慢」而沒人察覺。</p>
<h3 id="量測方式">量測方式</h3>
<p>最簡單的量測是從本機或 CI 環境定期 curl：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 量測回應時間（秒），只看 time_total</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">curl -o /dev/null -s -w <span class="s2">&#34;%{time_total}\n&#34;</span> https://example.com</span></span></code></pre></div><p>把這段做成 GitHub Actions 的 scheduled workflow，每小時跑一次、把結果追加到 repo 的 CSV 檔案，就有了一條回應時間的趨勢線：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">on</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="nt">schedule</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="nt">cron</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;0 * * * *&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="nt">jobs</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="nt">perf-check</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="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="nt">steps</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="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="sd">          TIME=$(curl -o /dev/null -s -w &#34;%{time_total}&#34; https://example.com)
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="sd">          echo &#34;$(date -u +%Y-%m-%dT%H:%M:%SZ),$TIME&#34; &gt;&gt; perf-log.csv</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">git add perf-log.csv &amp;&amp; git commit -m &#34;perf check&#34; &amp;&amp; git push</span></span></span></code></pre></div><p>這條趨勢線本身就是監控：回應時間連續幾個小時上升，代表某個東西在劣化（DB 查詢變慢、磁碟快滿、PHP process 卡住）。</p>
<h3 id="頁面效能">頁面效能</h3>
<p>Google PageSpeed Insights（免費、不需安裝）分析前端載入效能，包含 LCP、CLS、FID 等 Core Web Vitals。對 legacy PHP 站台有用的是它會指出渲染阻塞的 CSS/JS、未壓縮的圖片、缺少快取 header 這類不需要動後端就能改善的問題。</p>
<h3 id="資料庫效能需改-code">資料庫效能（需改 code）</h3>
<p>如果能修改 PHP 程式碼，在資料庫查詢前後加計時、超過閾值就寫 error_log：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="nv">$start</span> <span class="o">=</span> <span class="nx">microtime</span><span class="p">(</span><span class="k">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nv">$result</span> <span class="o">=</span> <span class="nv">$pdo</span><span class="o">-&gt;</span><span class="na">query</span><span class="p">(</span><span class="nv">$sql</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nv">$elapsed</span> <span class="o">=</span> <span class="nx">microtime</span><span class="p">(</span><span class="k">true</span><span class="p">)</span> <span class="o">-</span> <span class="nv">$start</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$elapsed</span> <span class="o">&gt;</span> <span class="mf">1.0</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">error_log</span><span class="p">(</span><span class="nx">sprintf</span><span class="p">(</span><span class="s2">&#34;Slow query (%.2fs): %s&#34;</span><span class="p">,</span> <span class="nv">$elapsed</span><span class="p">,</span> <span class="nx">substr</span><span class="p">(</span><span class="nv">$sql</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">200</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>累積一段時間後，從 error_log 裡 grep <code>Slow query</code> 就能看出哪些查詢是效能瓶頸。這不是完整的 APM，但在沒有 agent 的環境裡是最接近 slow query log 的替代方案。</p>
<h2 id="帳單與流量異常偵測">帳單與流量異常偵測</h2>
<p>這類主機通常按流量或磁碟空間計費，異常流量（bot 掃描、DDoS、爬蟲）會讓帳單飆高或觸發主機商的流量限制。</p>
<h3 id="流量監控">流量監控</h3>
<p>主機控制面板（cPanel 的 AWStats 或 Webalizer）提供基本的流量分析——top referrer、top page、bot 流量佔比。每月檢查一次，重點看：</p>
<ul>
<li>bot 流量佔比是否異常高（&gt;50% 通常代表有爬蟲）</li>
<li>單一 IP 的請求量是否異常集中</li>
<li>帶寬使用量的趨勢（月增超過 20% 且沒有對應的業務成長要查原因）</li>
</ul>
<h3 id="客戶端分析不需-server-安裝">客戶端分析（不需 server 安裝）</h3>
<p>Google Analytics 或 Plausible（隱私友善替代品）只需要在頁面加一段 JavaScript。它們追蹤的是真實使用者的瀏覽行為（page view、session、referrer），跟 server 端的 access log 互補：server log 看所有請求（含 bot），GA/Plausible 只看真實瀏覽器。</p>
<h3 id="cloudflare-免費方案">Cloudflare 免費方案</h3>
<p>如果 DNS 可以切換，把 domain 接上 Cloudflare（免費方案）提供三個能力而不需要動 server：</p>
<ul>
<li><strong>流量分析</strong>：比 AWStats 更即時、有地理分佈和 bot 過濾</li>
<li><strong>DDoS 保護</strong>：基本的 Layer 3/4 防護免費</li>
<li><strong>CDN 快取</strong>：靜態資源（CSS/JS/圖片）由 Cloudflare 快取、減輕 origin 負擔</li>
</ul>
<p>設定只需要把 domain 的 nameserver 改成 Cloudflare 提供的 NS、原始 DNS record 在 Cloudflare 重建。對無 SSH 環境的站台來說這是投資報酬率最高的單一改善動作——不動 server、不改 code、但同時拿到流量可見性和基本防護。</p>
<h2 id="整合成最低成本監控方案">整合成最低成本監控方案</h2>
<p>按投入程度分三層，每一層都包含上一層：</p>
<table>
  <thead>
      <tr>
          <th>層級</th>
          <th>組成</th>
          <th>月費</th>
          <th>覆蓋</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Tier 1（零成本）</td>
          <td>UptimeRobot free + Sentry free + Google Analytics</td>
          <td>$0</td>
          <td>可用性 + 錯誤追蹤 + 流量</td>
      </tr>
      <tr>
          <td>Tier 2（最低付費）</td>
          <td>+Better Stack ($19/mo) + Cloudflare free</td>
          <td>~$19</td>
          <td>+incident 管理 + 流量分析 + CDN</td>
      </tr>
      <tr>
          <td>Tier 3（升級路徑）</td>
          <td>遷移到 VPS → 安裝 APM agent → 對齊模組六的 IaC 監控</td>
          <td>依 VPS</td>
          <td>完整 server 端可觀測性</td>
      </tr>
  </tbody>
</table>
<p>Tier 1 在接手當天就能建好（30 分鐘設定 UptimeRobot + Sentry + GA），零成本提供基本的「服務掛了會知道、程式碼出錯會收到、流量異常看得到」的覆蓋。Tier 2 適合站台有營收或合約 SLA 要求時。Tier 3 是離開無 SSH 環境後的正規化路徑，監控從外部觀測升級為 server 端全面可觀測性，見<a href="/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">模組六：可觀測性與 log</a>。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">無 SSH 的 FTP / 面板管理環境接管</a>：本篇的母篇，監控建立在盤點與本地環境之後</li>
<li>→ <a href="/blog/infra/takeover/legacy-code-versioning-deployment/" data-link-title="程式碼版控與 FTP 部署紀律" data-link-desc="無 SSH 環境的 PHP 專案的程式碼怎麼從 FTP 拉回來建 Git repo、設定檔怎麼分離、FTP 部署怎麼建立可追蹤的流程、以及怎麼用 CI 取代手動上傳">程式碼版控與 FTP 部署紀律</a>：部署後的驗證用監控確認服務正常</li>
<li>→ <a href="/blog/infra/takeover/legacy-php-security-audit/" data-link-title="Legacy PHP 的安全盤點" data-link-desc="接手 legacy PHP 專案後的系統性安全審查：credential 掃描、PHP 版本風險、常見漏洞模式的 grep 偵測、.htaccess 防線、檔案權限、外部依賴與掃描工具">Legacy PHP 的安全盤點</a>：錯誤追蹤可能暴露安全問題（未捕捉的 SQL error、路徑洩漏）</li>
<li>→ <a href="/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">模組六：可觀測性與 log</a>：Tier 3 升級路徑的目標——有 server 存取後的 IaC 監控</li>
<li>→ <a href="/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring 監控體系</a>：客戶端行為訊號（SDK / Collector）的完整討論</li>
</ul>
]]></content:encoded></item><item><title>讀寫分離與查詢擴展</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/read-write-separation/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/read-write-separation/</guid><description>&lt;p>Monitor 的寫入路徑（SDK flush → HTTP endpoint → Storage）和讀取路徑（Dashboard 刷新、Debug 即席查詢、聚合趨勢、Rule engine 評估）在 SQLite 階段不太會互相干擾 — 事件量小、查詢簡單、WAL mode 讓讀寫各自進行。進入 PostgreSQL 層之後，兩條路徑的負載都會成長，而且成長方向不同。本章處理的是讀寫開始互相干擾時的辨識訊號和應對策略。&lt;/p>
&lt;h2 id="讀寫競爭的具體場景">讀寫競爭的具體場景&lt;/h2>
&lt;p>Monitor 的 PostgreSQL 層同時承擔三種負載，各自的資源消耗特性不同。&lt;/p>
&lt;h3 id="寫入負載">寫入負載&lt;/h3>
&lt;p>SDK flush 是 Monitor 的主要寫入來源。多個 SDK 同時 flush 時，collector 透過連線池並行寫入 PostgreSQL。每筆 INSERT 涉及主表寫入 + 索引更新（&lt;code>idx_type_ts&lt;/code>、&lt;code>idx_session&lt;/code>、&lt;code>idx_name&lt;/code>）。寫入量隨 SDK 數量和 flush 頻率線性成長。&lt;/p>
&lt;p>Downsample job 是另一種寫入：定期把原始事件聚合到 &lt;code>hourly_summary&lt;/code> / &lt;code>daily_summary&lt;/code>。Downsample 執行時同時做大量 SELECT（讀原始事件）和 INSERT（寫摘要），佔用連線和 I/O。&lt;/p>
&lt;h3 id="dashboard-讀取負載">Dashboard 讀取負載&lt;/h3>
&lt;p>Dashboard 是穩定的高頻背景負載。總覽頁每 30 秒刷新、Error 列表每分鐘刷新、趨勢圖每分鐘重算。每次刷新執行一到多個聚合查詢（&lt;code>GROUP BY name&lt;/code>、&lt;code>COUNT(*)&lt;/code>、時間分桶）。&lt;/p>
&lt;p>Dashboard 查詢的掃描量隨資料累積成長。「過去 7 天每小時的 error 數量」在第一週掃描幾千筆，三個月後掃描幾十萬筆。如果沒有用 &lt;code>hourly_summary&lt;/code> 摘要表、而是直接查原始 events 表，查詢時間會隨資料量線性增加。&lt;/p>
&lt;h3 id="debug-即席讀取負載">Debug 即席讀取負載&lt;/h3>
&lt;p>Debug 查詢是偶發的突增負載。開發者在排查問題時，可能用 session_id 拉出整條事件鏈、用 error name 掃描最近 N 筆 stack trace、或用 &lt;code>data-&amp;gt;&amp;gt;'duration_ms'&lt;/code> 做 ad-hoc 效能分析。這些查詢的特徵是不可預測、偶發但延遲敏感 — 開發者在等結果。&lt;/p>
&lt;h3 id="競爭發生在哪">競爭發生在哪&lt;/h3>
&lt;p>三種負載打同一個 PostgreSQL 時，競爭集中在兩個資源：&lt;/p>
&lt;p>&lt;strong>連線池&lt;/strong>：collector 的 &lt;code>SetMaxOpenConns&lt;/code> 是固定值（例如 20）。如果 ingestion 佔用 15 條連線做批次 INSERT、dashboard 需要 3 條做聚合查詢、debug 需要 2 條做 ad-hoc 查詢 — 剛好佔滿。這時 downsample job 啟動需要連線，會排隊等待。&lt;/p>
&lt;p>&lt;strong>I/O 頻寬&lt;/strong>：聚合查詢需要掃描大量資料（sequential scan 或 index scan + heap access），跟 INSERT 的隨機寫入搶磁碟 I/O。在 HDD 或低階 SSD 上，一個 heavy 聚合查詢可以讓同時進行的 INSERT latency 從毫秒跳到十毫秒。&lt;/p>
&lt;p>&lt;strong>鎖競爭&lt;/strong>：PostgreSQL 的 MVCC 讓 SELECT 跟 INSERT 不互相阻塞（reader 不等 writer），但 Downsample 的 INSERT OR REPLACE 跟 ingestion 的 INSERT 可能在同一張表上競爭 row-level lock。長時間的 aggregation query 也可能觸發 &lt;code>idle in transaction&lt;/code> 問題，佔住連線不釋放。&lt;/p>
&lt;h2 id="辨識訊號">辨識訊號&lt;/h2>
&lt;p>讀寫競爭的辨識訊號是「寫入跟讀取的效能同時退化，而且退化是交互的」：&lt;/p>
&lt;ul>
&lt;li>Ingestion 的 INSERT latency 在 dashboard 刷新時段（每 30 秒）出現週期性尖峰&lt;/li>
&lt;li>Dashboard 的聚合查詢在 SDK 高峰 flush 時段（例：每整點、app 啟動潮）變慢&lt;/li>
&lt;li>Debug 即席查詢在 downsample job 執行期間 timeout&lt;/li>
&lt;li>PostgreSQL 的 &lt;code>pg_stat_activity&lt;/code> 顯示多個 &lt;code>idle in transaction&lt;/code> 或 &lt;code>waiting&lt;/code> 狀態&lt;/li>
&lt;li>連線池使用率持續高於 80%，偶發 &lt;code>too many connections&lt;/code> 或連線等待&lt;/li>
&lt;/ul>
&lt;p>單純的寫入慢（沒有讀取影響）或單純的查詢慢（沒有寫入影響）不是讀寫競爭，可能是索引缺失或查詢效率問題。讀寫競爭的特徵是「兩邊同時退化、一邊忙的時候另一邊也變慢」。&lt;/p></description><content:encoded><![CDATA[<p>Monitor 的寫入路徑（SDK flush → HTTP endpoint → Storage）和讀取路徑（Dashboard 刷新、Debug 即席查詢、聚合趨勢、Rule engine 評估）在 SQLite 階段不太會互相干擾 — 事件量小、查詢簡單、WAL mode 讓讀寫各自進行。進入 PostgreSQL 層之後，兩條路徑的負載都會成長，而且成長方向不同。本章處理的是讀寫開始互相干擾時的辨識訊號和應對策略。</p>
<h2 id="讀寫競爭的具體場景">讀寫競爭的具體場景</h2>
<p>Monitor 的 PostgreSQL 層同時承擔三種負載，各自的資源消耗特性不同。</p>
<h3 id="寫入負載">寫入負載</h3>
<p>SDK flush 是 Monitor 的主要寫入來源。多個 SDK 同時 flush 時，collector 透過連線池並行寫入 PostgreSQL。每筆 INSERT 涉及主表寫入 + 索引更新（<code>idx_type_ts</code>、<code>idx_session</code>、<code>idx_name</code>）。寫入量隨 SDK 數量和 flush 頻率線性成長。</p>
<p>Downsample job 是另一種寫入：定期把原始事件聚合到 <code>hourly_summary</code> / <code>daily_summary</code>。Downsample 執行時同時做大量 SELECT（讀原始事件）和 INSERT（寫摘要），佔用連線和 I/O。</p>
<h3 id="dashboard-讀取負載">Dashboard 讀取負載</h3>
<p>Dashboard 是穩定的高頻背景負載。總覽頁每 30 秒刷新、Error 列表每分鐘刷新、趨勢圖每分鐘重算。每次刷新執行一到多個聚合查詢（<code>GROUP BY name</code>、<code>COUNT(*)</code>、時間分桶）。</p>
<p>Dashboard 查詢的掃描量隨資料累積成長。「過去 7 天每小時的 error 數量」在第一週掃描幾千筆，三個月後掃描幾十萬筆。如果沒有用 <code>hourly_summary</code> 摘要表、而是直接查原始 events 表，查詢時間會隨資料量線性增加。</p>
<h3 id="debug-即席讀取負載">Debug 即席讀取負載</h3>
<p>Debug 查詢是偶發的突增負載。開發者在排查問題時，可能用 session_id 拉出整條事件鏈、用 error name 掃描最近 N 筆 stack trace、或用 <code>data-&gt;&gt;'duration_ms'</code> 做 ad-hoc 效能分析。這些查詢的特徵是不可預測、偶發但延遲敏感 — 開發者在等結果。</p>
<h3 id="競爭發生在哪">競爭發生在哪</h3>
<p>三種負載打同一個 PostgreSQL 時，競爭集中在兩個資源：</p>
<p><strong>連線池</strong>：collector 的 <code>SetMaxOpenConns</code> 是固定值（例如 20）。如果 ingestion 佔用 15 條連線做批次 INSERT、dashboard 需要 3 條做聚合查詢、debug 需要 2 條做 ad-hoc 查詢 — 剛好佔滿。這時 downsample job 啟動需要連線，會排隊等待。</p>
<p><strong>I/O 頻寬</strong>：聚合查詢需要掃描大量資料（sequential scan 或 index scan + heap access），跟 INSERT 的隨機寫入搶磁碟 I/O。在 HDD 或低階 SSD 上，一個 heavy 聚合查詢可以讓同時進行的 INSERT latency 從毫秒跳到十毫秒。</p>
<p><strong>鎖競爭</strong>：PostgreSQL 的 MVCC 讓 SELECT 跟 INSERT 不互相阻塞（reader 不等 writer），但 Downsample 的 INSERT OR REPLACE 跟 ingestion 的 INSERT 可能在同一張表上競爭 row-level lock。長時間的 aggregation query 也可能觸發 <code>idle in transaction</code> 問題，佔住連線不釋放。</p>
<h2 id="辨識訊號">辨識訊號</h2>
<p>讀寫競爭的辨識訊號是「寫入跟讀取的效能同時退化，而且退化是交互的」：</p>
<ul>
<li>Ingestion 的 INSERT latency 在 dashboard 刷新時段（每 30 秒）出現週期性尖峰</li>
<li>Dashboard 的聚合查詢在 SDK 高峰 flush 時段（例：每整點、app 啟動潮）變慢</li>
<li>Debug 即席查詢在 downsample job 執行期間 timeout</li>
<li>PostgreSQL 的 <code>pg_stat_activity</code> 顯示多個 <code>idle in transaction</code> 或 <code>waiting</code> 狀態</li>
<li>連線池使用率持續高於 80%，偶發 <code>too many connections</code> 或連線等待</li>
</ul>
<p>單純的寫入慢（沒有讀取影響）或單純的查詢慢（沒有寫入影響）不是讀寫競爭，可能是索引缺失或查詢效率問題。讀寫競爭的特徵是「兩邊同時退化、一邊忙的時候另一邊也變慢」。</p>
<h2 id="read-replica-分離">Read Replica 分離</h2>
<p>Read replica 是 Monitor 在 PostgreSQL 層後的第一步讀寫分離。概念簡單：寫入走 primary、讀取走 replica，兩者物理隔離。</p>
<h3 id="架構">架構</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">SDK ──→ Collector
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">             │
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        ┌────┴──────────┐
</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">   Primary (write)   Replica (read)
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        │                │
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        │  replication →  │
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        │                │
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        ▼                ▼
</span></span><span class="line"><span class="ln">10</span><span class="cl">   Ingestion        Dashboard + Debug
</span></span><span class="line"><span class="ln">11</span><span class="cl">   Downsample       聚合查詢</span></span></code></pre></div><p>Collector 持有兩個連線池 — 一個連 primary（用於 <code>Store()</code>、<code>Downsample()</code>、<code>Purge()</code>），一個連 replica（用於 <code>Query()</code>、<code>Aggregate()</code>、Dashboard 的所有讀取）。</p>
<h3 id="storage-interface-的調整">Storage interface 的調整</h3>
<p>現有的 <code>BasicStorage</code> interface 不需要改動。實作層在初始化時接收兩個 DSN（primary + replica），內部根據操作類型選擇連線池：</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">type</span> <span class="nx">PostgresStorage</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">primary</span> <span class="o">*</span><span class="nx">sql</span><span class="p">.</span><span class="nx">DB</span>  <span class="c1">// write operations</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">replica</span> <span class="o">*</span><span class="nx">sql</span><span class="p">.</span><span class="nx">DB</span>  <span class="c1">// read operations (nil = use primary)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>當 replica DSN 未設定時，所有操作走 primary — 行為跟目前一樣，不破壞 single-instance 部署。</p>
<h3 id="replica-lag-對各查詢場景的影響">Replica lag 對各查詢場景的影響</h3>
<p>PostgreSQL streaming replication 的 lag 在同 AZ 通常 &lt; 100ms，跨 AZ 可能到秒級。各查詢場景對 lag 的容忍度不同：</p>
<table>
  <thead>
      <tr>
          <th>查詢場景</th>
          <th>Lag 容忍度</th>
          <th>走哪裡</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Dashboard 總覽</td>
          <td>秒級可接受</td>
          <td>Replica</td>
          <td>30 秒刷新一次，lag &lt; 1 秒不影響判讀</td>
      </tr>
      <tr>
          <td>Error 列表</td>
          <td>秒級可接受</td>
          <td>Replica</td>
          <td>新 error 晚一秒出現在列表上不影響 debug</td>
      </tr>
      <tr>
          <td>聚合趨勢圖</td>
          <td>分鐘級可接受</td>
          <td>Replica</td>
          <td>趨勢圖本身就是歷史資料的聚合</td>
      </tr>
      <tr>
          <td>Funnel / Cohort</td>
          <td>分鐘級可接受</td>
          <td>Replica</td>
          <td>分析查詢看的是天級或週級的資料</td>
      </tr>
      <tr>
          <td>Debug 即席查詢</td>
          <td>數秒可能不接受</td>
          <td>Primary</td>
          <td>開發者剛送一筆 test event 想立刻查到</td>
      </tr>
      <tr>
          <td>Rule engine 查歷史</td>
          <td>秒級可接受</td>
          <td>Replica</td>
          <td>Rule 的閾值判斷容忍短暫延遲</td>
      </tr>
  </tbody>
</table>
<p>Debug 即席查詢的 lag 問題是 read-after-write 一致性 — 開發者從 SDK 送出 test event 後立刻查詢，如果查 replica 可能還沒同步到。解法是讓 debug query API 提供 <code>consistency=strong</code> 參數，強制走 primary。預設走 replica（大部分 debug 查的是歷史資料），只有需要 read-after-write 時切 primary。</p>
<h3 id="引入時機">引入時機</h3>
<p>Read replica 的引入時機是「辨識訊號」段列出的讀寫競爭訊號持續出現，而且已經做過基本最佳化（索引補齊、dashboard 改讀 summary 表、downsample job 調整執行時段避開高峰）仍然不夠。</p>
<p>引入 read replica 的成本是多一台 PostgreSQL 實例（或 managed service 的 read replica 選項）和 replication 設定。Monitor 的 PostgreSQL 層已經承擔外部 DB 的運維成本，加 replica 是增量而非從零開始。</p>
<h2 id="預聚合作為讀取面的第一道防線">預聚合作為讀取面的第一道防線</h2>
<p>在引入 read replica 之前，預聚合是降低讀取負載最有效的方式 — 不改架構、不加機器、只改查詢的資料來源。</p>
<p>Monitor 已經有 <code>hourly_summary</code> 跟 <code>daily_summary</code> 兩張摘要表（見 <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a>）。Dashboard 的趨勢圖跟 Error 計數應該讀摘要表而非原始 events 表。</p>
<p>預聚合沒處理到的讀取負載是「需要原始事件的查詢」— Debug 即席查詢（看 stack trace）、Session 回放（看事件序列）、Funnel 分析（跨 session JOIN）。這些查詢必須掃描原始資料，預聚合無法取代。當這類查詢的負載開始擠壓寫入時，才是引入 read replica 的時機。</p>
<p>概念上，預聚合就是 <a href="/blog/backend/knowledge-cards/recording-rule/" data-link-title="Recording Rule" data-link-desc="說明把 query-time 聚合計算推到寫入時的 pre-aggregation 機制">recording rule</a> 在關聯式資料庫的實作。Downsample job 定期執行 aggregation query、把結果寫入 summary 表，dashboard 讀 summary 表而非重算 raw data。Monitor 的 <code>hourly_summary</code> 等同於 Prometheus 的 recording rule output、PostgreSQL 的 <a href="/blog/backend/knowledge-cards/materialized-view/" data-link-title="Materialized View" data-link-desc="說明預先計算並儲存查詢結果以加速讀取的資料結構">materialized view</a> 等同於 TSDB 的 continuous aggregate。</p>
<h2 id="cqrs-的判讀訊號">CQRS 的判讀訊號</h2>
<p>Read replica 解決的是「讀寫搶同一台機器的 I/O 跟連線」。當問題不只是資源競爭、而是讀寫的資料形狀根本不同時，read replica 不夠 — 需要獨立的 <a href="/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model</a>。</p>
<p><a href="/blog/backend/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="說明讀寫不對稱時為何需要分離查詢與寫入責任、分離的判準與代價">CQRS</a> 的完整概念見知識卡。以下是 Monitor 情境下，什麼訊號出現時該考慮從 read replica 往 CQRS 方向演進。</p>
<h3 id="訊號一讀取需要的資料形狀跟-events-表差異太大">訊號一：讀取需要的資料形狀跟 events 表差異太大</h3>
<p>Monitor 的 events 表是 append-only 的正規化結構（一筆事件一個 row）。如果讀取面需要的是：</p>
<ul>
<li>每個 user 的行為摘要（最近登入、最常用功能、累計 error 數）— 需要跨所有事件聚合成 per-user profile</li>
<li>即時的 error fingerprint 索引（相同 stack trace 的 error 自動分群、計數、追蹤首次出現時間）— 需要維護一張反正規化的 error group 表</li>
<li>跨 session 的 funnel conversion 快照 — 需要維護一張 pre-computed funnel 表</li>
</ul>
<p>這些讀取形狀無法用 <code>SELECT FROM events</code> + 索引高效產生，需要獨立的 read model 持續從 events 推算。</p>
<h3 id="訊號二預聚合的種類和刷新頻率失控">訊號二：預聚合的種類和刷新頻率失控</h3>
<p>Summary 表從 2 張（hourly + daily）增長到 5 張、10 張，每張的刷新頻率從每小時變成每分鐘。Downsample job 的執行時間從秒級增長到分鐘級，開始擠壓 ingestion。</p>
<p>這時候 summary 表已經不只是「摘要」，而是事實上的 read model — 專門為讀取需求設計的獨立資料結構。承認這個事實、把 summary 表的維護從 Downsample job 拆出來成為獨立的 projection consumer，就是進入 <a href="/blog/backend/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="說明讀寫不對稱時為何需要分離查詢與寫入責任、分離的判準與代價">CQRS</a> 的起點。</p>
<h3 id="訊號三讀取跟寫入需要獨立擴展">訊號三：讀取跟寫入需要獨立擴展</h3>
<p>寫入量穩定（SDK 數量不變），但讀取面因為新增 dashboard、新增分析維度、新增使用者而持續成長。Read replica 可以加多台分攤讀取，但每台 replica 仍然存的是跟 primary 一樣的 events 表結構 — 讀取查詢的複雜度不變，只是分攤到更多機器。</p>
<p>獨立的 read model 可以用完全不同的 schema（反正規化、pre-joined、pre-aggregated），讓讀取查詢從 O(N) 的聚合變成 O(1) 的 lookup。這是 CQRS 的核心價值 — 讀取面的效能不再受限於寫入面的資料結構。</p>
<h3 id="monitor-目前的位置">Monitor 目前的位置</h3>
<p>Monitor 目前在「SQLite → PostgreSQL → Read Replica」這條路徑的前半段。MVP 用 SQLite、功能需求觸發 PostgreSQL、讀寫競爭觸發 Read Replica。CQRS 是更遠的演進方向，只有上述三個訊號明確出現時才值得引入。</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">SQLite（零依賴）
</span></span><span class="line"><span class="ln">2</span><span class="cl">  → PostgreSQL（聚合分析觸發）
</span></span><span class="line"><span class="ln">3</span><span class="cl">    → 預聚合 summary 表（讀取負載觸發）
</span></span><span class="line"><span class="ln">4</span><span class="cl">      → Read Replica（讀寫競爭觸發）
</span></span><span class="line"><span class="ln">5</span><span class="cl">        → 獨立 read model / CQRS（資料形狀不對稱觸發）</span></span></code></pre></div><p>每一步都是被具體的效能訊號或功能需求推動的，跟 Monitor 整體的「按觀察到的瓶頸切換」原則一致。教學的價值在於讓讀者在每一步都知道「下一步是什麼、什麼訊號出現時該走」— 而不是在 SQLite 階段就預先設計 CQRS。</p>
<h2 id="跟-backend-的概念對照">跟 Backend 的概念對照</h2>
<p>Monitor 的讀寫分離路徑跟 backend 教材的概念有直接對應：</p>
<table>
  <thead>
      <tr>
          <th>Monitor 演進階段</th>
          <th>Backend 對應概念</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SQLite WAL（讀寫各自進行）</td>
          <td><a href="/blog/backend/knowledge-cards/write-ahead-log/" data-link-title="Write-Ahead Log" data-link-desc="說明資料庫如何先寫入 log 再合併回主資料，以提供持久性與崩潰復原">WAL mode</a> 的 reader-writer 並行</td>
      </tr>
      <tr>
          <td>PostgreSQL summary 表</td>
          <td><a href="/blog/backend/knowledge-cards/materialized-view/" data-link-title="Materialized View" data-link-desc="說明預先計算並儲存查詢結果以加速讀取的資料結構">Materialized view</a> 的最簡實作</td>
      </tr>
      <tr>
          <td>Read replica</td>
          <td><a href="/blog/backend/01-database/state-ownership-query-boundary/" data-link-title="1.8 State Ownership 與 Query Boundary" data-link-desc="正式狀態 vs 派生狀態的責任分層、CQRS / event sourcing / materialized view、四種 query 邊界">1.8 Query Boundary</a> 的讀寫分流</td>
      </tr>
      <tr>
          <td>獨立 read model</td>
          <td><a href="/blog/backend/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="說明讀寫不對稱時為何需要分離查詢與寫入責任、分離的判準與代價">CQRS</a> + <a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">Projection</a></td>
      </tr>
      <tr>
          <td>Downsample job → 獨立 worker</td>
          <td><a href="/blog/backend/knowledge-cards/event-sourcing/" data-link-title="Event Sourcing" data-link-desc="說明用 append-only 事件流取代 mutable state 作為正式紀錄的設計模式、需求判準與代價">Event sourcing</a> 架構中 projection consumer 的起點</td>
      </tr>
  </tbody>
</table>
<p>Monitor 的規模演進路徑是 backend 概念的具體實例 — 從自用工具到小型服務、從單機到讀寫分離、從 summary 表到可能的 CQRS，每一步都能回到 backend 教材找到概念基礎。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Storage backend 的可插拔架構 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a></li>
<li>功能分層的定義 → <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a></li>
<li>Ingestion 端的流量防線 → <a href="/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling</a></li>
<li>讀寫分離的通用概念 → <a href="/blog/backend/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="說明讀寫不對稱時為何需要分離查詢與寫入責任、分離的判準與代價">CQRS 知識卡</a></li>
<li>資料庫層的讀寫分離設計 → <a href="/blog/backend/01-database/state-ownership-query-boundary/" data-link-title="1.8 State Ownership 與 Query Boundary" data-link-desc="正式狀態 vs 派生狀態的責任分層、CQRS / event sourcing / materialized view、四種 query 邊界">1.8 State Ownership 與 Query Boundary</a></li>
<li>觀測領域的讀取路徑設計 → <a href="/blog/backend/04-observability/observability-query-design/" data-link-title="4.23 觀測查詢設計" data-link-desc="把觀測資料的讀取路徑當系統設計問題處理：三種查詢模式、storage tiering、pre-aggregation 與資源治理">4.23 觀測查詢設計</a></li>
</ul>
]]></content:encoded></item><item><title>Container 部署設計</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/container-deployment/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/container-deployment/</guid><description>&lt;p>Container 部署讓 collector 完全隔離於 host 環境，開源使用者用 &lt;code>docker run&lt;/code> 一行部署，不需要安裝 Go 或管理 binary 版本。但 SQLite 在 container 中有特殊的 I/O 和持久化考量 — overlay filesystem 的寫入延遲和 container 生命週期對資料持久性的影響需要在部署設計中處理。&lt;/p>
&lt;h2 id="dockerfile-設計">Dockerfile 設計&lt;/h2>
&lt;p>Multi-stage build 把編譯環境和執行環境分離。Build stage 用 Go 官方 image 編譯 binary，runtime stage 只包含 binary 和必要的 CA 憑證。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dockerfile" data-lang="dockerfile">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">FROM&lt;/span>&lt;span class="s"> golang:1.22-alpine AS build&lt;/span>&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">WORKDIR&lt;/span>&lt;span class="s"> /src&lt;/span>&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">COPY&lt;/span> go.mod go.sum ./&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">RUN&lt;/span> go mod download&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">COPY&lt;/span> . .&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">RUN&lt;/span> &lt;span class="nv">CGO_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">0&lt;/span> go build -o /collector ./cmd/collector&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="s"> alpine:3.20&lt;/span>&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">RUN&lt;/span> apk add --no-cache ca-certificates tzdata&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">COPY&lt;/span> --from&lt;span class="o">=&lt;/span>build /collector /usr/local/bin/collector&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">RUN&lt;/span> adduser -D -u &lt;span class="m">1000&lt;/span> monitor&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">USER&lt;/span>&lt;span class="s"> monitor&lt;/span>&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">EXPOSE&lt;/span>&lt;span class="s"> 8080&lt;/span>&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">ENTRYPOINT&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;collector&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>最終 image 包含 Go binary（~15MB）+ alpine base（~7MB）+ ca-certificates，總大小目標 &amp;lt; 25MB。用 &lt;code>scratch&lt;/code> 替代 &lt;code>alpine&lt;/code> 可以再小 7MB，但失去 shell debug 能力。&lt;/p>
&lt;h2 id="sqlite-在-container-中的-io-考量">SQLite 在 Container 中的 I/O 考量&lt;/h2>
&lt;p>Docker 的 overlay2 storage driver 在每次 fsync 時經過 overlay 層。SQLite 的 WAL mode 依賴 fsync 確保寫入持久性 — 每筆 transaction commit 觸發一次 fsync。Overlay 層增加的延遲讓每筆 fsync 慢 20-40%（取決於 host 的 storage driver 和檔案系統）。&lt;/p>
&lt;h3 id="volume-mount-繞過-overlay">Volume mount 繞過 overlay&lt;/h3>
&lt;p>把 SQLite 的資料目錄掛載為 host volume（&lt;code>-v /host/data:/data&lt;/code>），SQLite 直接寫 host 檔案系統、繞過 overlay 層。寫入效能和同機部署的 binary 版本相當。&lt;/p>
&lt;p>不用 volume mount 的風險：container 刪除時 overlay 層的資料一起消失。&lt;code>docker rm&lt;/code> = 所有事件資料消失。即使只是 &lt;code>docker run&lt;/code> 新版本的 image 也會建立新 container，舊 container 的資料不會自動遷移。&lt;/p>
&lt;h2 id="volume-mount-設計">Volume Mount 設計&lt;/h2>
&lt;p>兩個目錄分開掛載，職責和權限不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Mount&lt;/th>
 &lt;th>Container 路徑&lt;/th>
 &lt;th>Host 路徑（範例）&lt;/th>
 &lt;th>權限&lt;/th>
 &lt;th>內容&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>資料&lt;/td>
 &lt;td>&lt;code>/data&lt;/code>&lt;/td>
 &lt;td>&lt;code>./monitor-data&lt;/code>&lt;/td>
 &lt;td>read-write&lt;/td>
 &lt;td>SQLite DB + WAL + 匯出檔&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>設定&lt;/td>
 &lt;td>&lt;code>/config&lt;/code>&lt;/td>
 &lt;td>&lt;code>./monitor-config&lt;/code>&lt;/td>
 &lt;td>read-only&lt;/td>
 &lt;td>retention config + rule config + sensor config&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Container 內用非 root user（UID 1000）執行。Host 的 volume 目錄 ownership 需要對應：&lt;/p></description><content:encoded><![CDATA[<p>Container 部署讓 collector 完全隔離於 host 環境，開源使用者用 <code>docker run</code> 一行部署，不需要安裝 Go 或管理 binary 版本。但 SQLite 在 container 中有特殊的 I/O 和持久化考量 — overlay filesystem 的寫入延遲和 container 生命週期對資料持久性的影響需要在部署設計中處理。</p>
<h2 id="dockerfile-設計">Dockerfile 設計</h2>
<p>Multi-stage build 把編譯環境和執行環境分離。Build stage 用 Go 官方 image 編譯 binary，runtime stage 只包含 binary 和必要的 CA 憑證。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">FROM</span><span class="s"> golang:1.22-alpine AS build</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="err"></span><span class="k">WORKDIR</span><span class="s"> /src</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="err"></span><span class="k">COPY</span> go.mod go.sum ./<span class="err">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="err"></span><span class="k">RUN</span> go mod download<span class="err">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="err"></span><span class="k">COPY</span> . .<span class="err">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="err"></span><span class="k">RUN</span> <span class="nv">CGO_ENABLED</span><span class="o">=</span><span class="m">0</span> go build -o /collector ./cmd/collector<span class="err">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="err"></span><span class="k">FROM</span><span class="s"> alpine:3.20</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="err"></span><span class="k">RUN</span> apk add --no-cache ca-certificates tzdata<span class="err">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="err"></span><span class="k">COPY</span> --from<span class="o">=</span>build /collector /usr/local/bin/collector<span class="err">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="err"></span><span class="k">RUN</span> adduser -D -u <span class="m">1000</span> monitor<span class="err">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="err"></span><span class="k">USER</span><span class="s"> monitor</span><span class="err">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="err"></span><span class="k">EXPOSE</span><span class="s"> 8080</span><span class="err">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="err"></span><span class="k">ENTRYPOINT</span> <span class="p">[</span><span class="s2">&#34;collector&#34;</span><span class="p">]</span></span></span></code></pre></div><p>最終 image 包含 Go binary（~15MB）+ alpine base（~7MB）+ ca-certificates，總大小目標 &lt; 25MB。用 <code>scratch</code> 替代 <code>alpine</code> 可以再小 7MB，但失去 shell debug 能力。</p>
<h2 id="sqlite-在-container-中的-io-考量">SQLite 在 Container 中的 I/O 考量</h2>
<p>Docker 的 overlay2 storage driver 在每次 fsync 時經過 overlay 層。SQLite 的 WAL mode 依賴 fsync 確保寫入持久性 — 每筆 transaction commit 觸發一次 fsync。Overlay 層增加的延遲讓每筆 fsync 慢 20-40%（取決於 host 的 storage driver 和檔案系統）。</p>
<h3 id="volume-mount-繞過-overlay">Volume mount 繞過 overlay</h3>
<p>把 SQLite 的資料目錄掛載為 host volume（<code>-v /host/data:/data</code>），SQLite 直接寫 host 檔案系統、繞過 overlay 層。寫入效能和同機部署的 binary 版本相當。</p>
<p>不用 volume mount 的風險：container 刪除時 overlay 層的資料一起消失。<code>docker rm</code> = 所有事件資料消失。即使只是 <code>docker run</code> 新版本的 image 也會建立新 container，舊 container 的資料不會自動遷移。</p>
<h2 id="volume-mount-設計">Volume Mount 設計</h2>
<p>兩個目錄分開掛載，職責和權限不同：</p>
<table>
  <thead>
      <tr>
          <th>Mount</th>
          <th>Container 路徑</th>
          <th>Host 路徑（範例）</th>
          <th>權限</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>資料</td>
          <td><code>/data</code></td>
          <td><code>./monitor-data</code></td>
          <td>read-write</td>
          <td>SQLite DB + WAL + 匯出檔</td>
      </tr>
      <tr>
          <td>設定</td>
          <td><code>/config</code></td>
          <td><code>./monitor-config</code></td>
          <td>read-only</td>
          <td>retention config + rule config + sensor config</td>
      </tr>
  </tbody>
</table>
<p>Container 內用非 root user（UID 1000）執行。Host 的 volume 目錄 ownership 需要對應：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">mkdir -p monitor-data monitor-config
</span></span><span class="line"><span class="ln">2</span><span class="cl">chown 1000:1000 monitor-data</span></span></code></pre></div><h2 id="graceful-shutdown">Graceful Shutdown</h2>
<p><code>docker stop</code> 送 SIGTERM → collector 收到後執行 shutdown 序列：</p>
<ol>
<li>停止接受新的 HTTP request（listener close）</li>
<li>等待 in-flight request 完成（5 秒 context timeout）</li>
<li>Flush pending writes（尚未寫入 storage 的事件，5 秒）</li>
<li>停止定期 job（downsample / purge / rule engine 定期評估）</li>
<li>SQLite WAL checkpoint（TRUNCATE mode，15 秒）</li>
<li>關閉 DB connection</li>
<li>退出</li>
</ol>
<p>步驟 2-5 合計超時上限 25 秒。這個序列對應 <a href="/blog/backend/05-deployment-platform/platform-lifecycle-contract/" data-link-title="5.6 Platform Lifecycle Contract" data-link-desc="說明 runtime、startup、readiness、liveness、shutdown 與 drain 如何組成平台生命週期合約。">Backend 5.6 Platform Lifecycle Contract</a> 的 shutdown → drain 狀態：步驟 1-2 是 drain（停接新工作、等在途完成），步驟 3-6 是 shutdown（flush 狀態和釋放資源）。Collector 屬於短 request API 的 workload 類型（drain 窗口 5-30 秒），但多了 WAL checkpoint 步驟，讓 shutdown 時間可能超過一般 HTTP 服務。PID 1 信號處理的設計考量（exec form、避免 shell 攔截 SIGTERM）見 <a href="/blog/backend/05-deployment-platform/container-runtime/" data-link-title="5.1 container 與 runtime" data-link-desc="整理 image、resource limit 與啟動行為">Backend 5.1 PID 1 與信號處理</a>。</p>
<p><code>docker stop</code> 預設等 10 秒後送 SIGKILL。如果 WAL checkpoint 在大量未 checkpoint 的資料下需要超過 10 秒，Docker Compose 可以調 <code>stop_grace_period: 30s</code>。</p>
<p>SQLite 的 WAL 設計支援 crash recovery — SIGKILL 後 WAL 檔案仍在，下次開啟 DB 時自動 replay。但非 graceful shutdown 可能丟失 channel 中尚未寫入的事件（已收到 HTTP 202 但還在 buffer 中的事件）。</p>
<h2 id="資源限制">資源限制</h2>
<table>
  <thead>
      <tr>
          <th>資源</th>
          <th>建議值（自用）</th>
          <th>建議值（小團隊）</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Memory</td>
          <td>256MB</td>
          <td>512MB</td>
          <td>Collector + SQLite page cache + Go runtime</td>
      </tr>
      <tr>
          <td>CPU</td>
          <td>0.5 核</td>
          <td>1 核</td>
          <td>I/O bound、CPU 通常不是瓶頸</td>
      </tr>
      <tr>
          <td>磁碟</td>
          <td>volume mount 容量</td>
          <td>volume mount 容量</td>
          <td>保留策略控制、和 host 磁碟共享</td>
      </tr>
  </tbody>
</table>
<p>Memory 限制設太緊會觸發 OOMKill — container 突然消失且無 log。設定 memory limit 前先觀察 collector 的 baseline 記憶體使用（<code>docker stats</code>），再乘以 1.5 安全係數。CPU request/limit 的設定策略（guaranteed vs burstable QoS）和 memory limit 與 OOM 的判讀見 <a href="/blog/backend/05-deployment-platform/container-runtime/" data-link-title="5.1 container 與 runtime" data-link-desc="整理 image、resource limit 與啟動行為">Backend 5.1 Resource Limit</a>。</p>
<h2 id="docker-compose-範例">Docker Compose 範例</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">services</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="nt">collector</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="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">tarrragon/monitor:latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">ports</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="s2">&#34;8080:8080&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">volumes</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="l">./monitor-data:/data</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span>- <span class="l">./monitor-config:/config:ro</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">environment</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="l">MONITOR_STORAGE=sqlite</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span>- <span class="l">MONITOR_DB_PATH=/data/events.db</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">    </span><span class="nt">stop_grace_period</span><span class="p">:</span><span class="w"> </span><span class="l">30s</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">    </span><span class="nt">deploy</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="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">        </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">          </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">256M</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">          </span><span class="nt">cpus</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;0.5&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;wget&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-q&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;--spider&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;http://localhost:8080/health&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">30s</span><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">3</span></span></span></code></pre></div><p><code>restart: unless-stopped</code> 讓 container 在 crash 或 host 重啟後自動恢復。<code>healthcheck</code> 讓 Docker 偵測 collector 是否真的在回應 — 只有 process 活著但 HTTP 不回應的場景也會被標記為 unhealthy。</p>
<h2 id="和同機部署的效能對照">和同機部署的效能對照</h2>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>同機 binary</th>
          <th>Container + volume mount</th>
          <th>Container 無 volume（overlay）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫入吞吐（Mac SSD）</td>
          <td>~5,000/sec</td>
          <td>~4,500/sec（-10%）</td>
          <td>~3,000/sec（-40%）</td>
      </tr>
      <tr>
          <td>寫入吞吐（Linux VPS）</td>
          <td>~3,000/sec</td>
          <td>~2,700/sec（-10%）</td>
          <td>~1,800/sec（-40%）</td>
      </tr>
      <tr>
          <td>查詢延遲</td>
          <td>baseline</td>
          <td>baseline（volume = 直接讀 host）</td>
          <td>+20%（overlay 讀取開銷小）</td>
      </tr>
      <tr>
          <td>啟動時間</td>
          <td>&lt; 100ms</td>
          <td>&lt; 500ms（container 啟動開銷）</td>
          <td>同左</td>
      </tr>
      <tr>
          <td>記憶體額外開銷</td>
          <td>0</td>
          <td>~10-20MB（container runtime）</td>
          <td>同左</td>
      </tr>
  </tbody>
</table>
<p>Volume mount 後效能差異只有 ~10%（Go HTTP handler 的 overhead 大於 volume mount 的 overhead）。不用 volume mount 時 overlay fs 的 fsync 開銷顯著 — 寫入吞吐降 40%。</p>
<h2 id="何時用-container何時用-binary">何時用 container、何時用 binary</h2>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>建議</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>開源使用者快速試用</td>
          <td>Container</td>
          <td><code>docker run</code> 一行、不需裝 Go</td>
      </tr>
      <tr>
          <td>長期自用部署</td>
          <td>Binary + systemd</td>
          <td>效能最佳、無 container overhead</td>
      </tr>
      <tr>
          <td>CI/CD 測試環境</td>
          <td>Container</td>
          <td>可拋棄式、每次乾淨環境</td>
      </tr>
      <tr>
          <td>Kubernetes 部署</td>
          <td>Container</td>
          <td>pod spec 標準化</td>
      </tr>
      <tr>
          <td>Raspberry Pi / 邊緣設備</td>
          <td>Binary</td>
          <td>低資源環境避免 container overhead</td>
      </tr>
  </tbody>
</table>
<h2 id="斷網環境的部署考量">斷網環境的部署考量</h2>
<p>Collector 在斷網環境（air-gapped）裡的部署跟連網環境的主要差異有三點。第一，SDK 的 endpoint 從外部 URL（<code>https://collect.example.com</code>）改為內網地址（<code>http://collector.internal:8080</code>），SDK 設定檔裡的 endpoint 要能按環境切換。第二，Collector 的 container image 無法從 Docker Hub 拉取——需要透過 content ferry 搬運映像、推送到內網的 private registry（Harbor 或 Docker Registry），Dockerfile 的 base image 來源也要改指 private registry。第三，Collector 的 storage backend 只能用本地磁碟或 NFS，不能用雲端物件儲存——SQLite backend 在斷網環境反而是優勢（零外部依賴），儲存容量規劃要在部署前就確定，因為斷網環境的磁碟擴容流程可能需要數週。</p>
<p>SDK 的 offline buffer（見<a href="/blog/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">SDK 設計：offline-buffer</a>）在斷網環境更重要——如果 Collector 重啟或暫時不可達，SDK 端的 buffer 是唯一能保住事件的機制。</p>
<p>斷網環境的 infra 層監控（Prometheus / Grafana / Loki）設定見<a href="/blog/infra/air-gapped/air-gapped-monitoring/" data-link-title="斷網環境的監控與可觀測性" data-link-desc="Self-hosted 監控（Prometheus &#43; Grafana）、離線 log 收集（Loki / ELK）、不能 phone home 的告警、NTP 時間同步">斷網環境的監控與可觀測性</a>。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>SQLite 效能基準的詳細數字 → <a href="/blog/monitoring/04-collector/sqlite-performance-baseline/" data-link-title="SQLite Backend 效能基準" data-link-desc="寫入吞吐 / 查詢延遲 / 資源消耗的量化預期 — 不同硬體環境下 SQLite 能撐多少、邊界在哪、怎麼實測">SQLite Backend 效能基準</a></li>
<li>可插拔 Storage Backend 架構 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a></li>
<li>Container runtime 通用原則（base image 選擇、build 可重現性、PID 1 信號處理）→ <a href="/blog/backend/05-deployment-platform/container-runtime/" data-link-title="5.1 container 與 runtime" data-link-desc="整理 image、resource limit 與啟動行為">Backend 5.1 Container 與 Runtime</a></li>
<li>生命週期合約（startup / readiness / drain / shutdown 的責任分類）→ <a href="/blog/backend/05-deployment-platform/platform-lifecycle-contract/" data-link-title="5.6 Platform Lifecycle Contract" data-link-desc="說明 runtime、startup、readiness、liveness、shutdown 與 drain 如何組成平台生命週期合約。">Backend 5.6 Platform Lifecycle Contract</a></li>
<li>容器化資源設計的通用原則 → <a href="/blog/devops/05-capacity-planning/container-resource-design/" data-link-title="容器化資源設計" data-link-desc="Container 的 memory / CPU / 磁碟限制設計 — 資源限制設太緊 OOMKill、設太鬆擠壓其他服務、overlay filesystem 的 I/O 影響">DevOps 容器化資源設計</a></li>
<li>服務探活和自動恢復 → <a href="/blog/devops/04-service-health/" data-link-title="模組四：服務探活與自動恢復" data-link-desc="服務掛了怎麼自動發現和恢復 — health check 設計、liveness vs readiness、systemd watchdog、process supervisor">DevOps 服務探活</a></li>
</ul>
]]></content:encoded></item><item><title>端到端資料完整性</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/data-integrity/</link><pubDate>Wed, 24 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/data-integrity/</guid><description>&lt;p>監控資料從事件產生到寫入 storage，經過 SDK buffer、HTTP transport、collector pipeline、storage backend 四個環節。每個環節都有丟失事件的可能 — 記憶體 buffer 溢出、網路超時、背壓丟棄、磁碟寫入失敗。端到端資料完整性的目標是讓每個損失點都是有意識的設計取捨，而非靜默丟失。&lt;/p>
&lt;p>監控資料和交易資料的根本差異在這裡：交易資料的損失會直接造成商業損害（少了一筆訂單），監控資料的損失影響的是可觀測性的覆蓋率（少了幾筆 event 不影響趨勢判斷，但漏了 error 可能讓 bug 晚幾天被發現）。這個差異決定了完整性設計的方向 — 追求的是「損失可控且可觀測」，而非「零損失」。合規稽核 log、billing event 和安全事件不適用這個假設 — 它們的損失有法規或商業後果，需要 at-least-once delivery 和獨立的持久化保證，通常用 transaction log 而非監控管線處理。&lt;/p>
&lt;h2 id="資料損失地圖">資料損失地圖&lt;/h2>
&lt;p>一筆事件從產生到持久化，依序經過四個環節。每個環節的損失類型、發生條件和影響範圍各不同。&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">事件產生 → [SDK buffer] → HTTP POST → [Collector pipeline] → [Storage]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ① ② ③ ④ ⑤&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="環節一事件產生階段">環節一：事件產生階段&lt;/h3>
&lt;p>事件在 SDK 的 &lt;code>monitor.event()&lt;/code> / &lt;code>monitor.error()&lt;/code> 被呼叫時產生，進入記憶體 buffer。這個階段的損失來自取樣和 SDK 初始化時序。&lt;/p>
&lt;p>&lt;strong>靜態取樣&lt;/strong>：SDK config 中設定的取樣率（例如 metric 類 0.1 = 每 10 筆只收 1 筆）是設計內的損失。取樣後的事件量直接影響後續所有環節的負載。取樣率的設定依據見&lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/sensor-lifecycle-management/" data-link-title="感測器生命週期管理" data-link-desc="產品生命週期的五個階段各啟用什麼感測器 — feature flag 整合、取樣率動態調整、感測器開關的可觀察性">感測器生命週期管理&lt;/a>。&lt;/p>
&lt;p>&lt;strong>SDK 未初始化&lt;/strong>：app 啟動後到 &lt;code>monitor.init()&lt;/code> 完成之間的事件會被丟棄。如果 init 排在其他初始化邏輯之後，啟動階段的 crash 可能漏捕。商業 SDK（Sentry、Crashlytics）用 native crash handler 在 SDK 層之外攔截這類 crash，自架方案通常接受這個損失。&lt;/p>
&lt;h3 id="環節二sdk-buffer-階段">環節二：SDK buffer 階段&lt;/h3>
&lt;p>事件進入記憶體 buffer 後，等待 flush 觸發。Buffer 溢出和 app 強制終止是這段路徑上的兩個風險。&lt;/p>
&lt;p>&lt;strong>FIFO 丟棄&lt;/strong>：記憶體 buffer 有容量上限（典型值 200-500 筆）。離線時間過長或事件產生速率過高時，buffer 滿了會丟棄最舊的事件。丟棄策略見&lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">離線 buffer 與重試&lt;/a>，優先級丟棄見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling 第一層&lt;/a>。&lt;/p>
&lt;p>&lt;strong>App 強制終止&lt;/strong>：iOS 的 &lt;code>kill&lt;/code>、Android 的 process death、Python 的 &lt;code>SIGKILL&lt;/code> — 記憶體 buffer 中未 flush 的事件全部遺失。&lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/batch-flush/" data-link-title="攢批送出策略" data-link-desc="flush interval / buffer size / flush on close 三個控制點決定事件何時離開 SDK — 平衡即時性和網路效率">攢批送出策略&lt;/a>的 close flush 嘗試在 app 正常退出時送出剩餘事件，但強制終止時連 close callback 都不會執行。&lt;/p>
&lt;p>&lt;strong>動態取樣&lt;/strong>：收到 collector 的 HTTP 429（Too Many Requests，表示 collector 過載）後，SDK 自動降低取樣率（從 1.0 降到 0.5 → 0.1）。這是對 collector 過載的回饋反應 — 損失的事件量隨背壓程度增加。和靜態取樣的差異是動態取樣在正常情況下不生效，只在過載時啟用。&lt;/p>
&lt;h3 id="環節三transport-階段">環節三：Transport 階段&lt;/h3>
&lt;p>SDK flush 時透過 HTTP POST 送出 batch。網路故障和重試耗盡構成 transport 層的主要損失。&lt;/p></description><content:encoded><![CDATA[<p>監控資料從事件產生到寫入 storage，經過 SDK buffer、HTTP transport、collector pipeline、storage backend 四個環節。每個環節都有丟失事件的可能 — 記憶體 buffer 溢出、網路超時、背壓丟棄、磁碟寫入失敗。端到端資料完整性的目標是讓每個損失點都是有意識的設計取捨，而非靜默丟失。</p>
<p>監控資料和交易資料的根本差異在這裡：交易資料的損失會直接造成商業損害（少了一筆訂單），監控資料的損失影響的是可觀測性的覆蓋率（少了幾筆 event 不影響趨勢判斷，但漏了 error 可能讓 bug 晚幾天被發現）。這個差異決定了完整性設計的方向 — 追求的是「損失可控且可觀測」，而非「零損失」。合規稽核 log、billing event 和安全事件不適用這個假設 — 它們的損失有法規或商業後果，需要 at-least-once delivery 和獨立的持久化保證，通常用 transaction log 而非監控管線處理。</p>
<h2 id="資料損失地圖">資料損失地圖</h2>
<p>一筆事件從產生到持久化，依序經過四個環節。每個環節的損失類型、發生條件和影響範圍各不同。</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">事件產生 → [SDK buffer] → HTTP POST → [Collector pipeline] → [Storage]
</span></span><span class="line"><span class="ln">2</span><span class="cl">     ①          ②            ③              ④                   ⑤</span></span></code></pre></div><h3 id="環節一事件產生階段">環節一：事件產生階段</h3>
<p>事件在 SDK 的 <code>monitor.event()</code> / <code>monitor.error()</code> 被呼叫時產生，進入記憶體 buffer。這個階段的損失來自取樣和 SDK 初始化時序。</p>
<p><strong>靜態取樣</strong>：SDK config 中設定的取樣率（例如 metric 類 0.1 = 每 10 筆只收 1 筆）是設計內的損失。取樣後的事件量直接影響後續所有環節的負載。取樣率的設定依據見<a href="/blog/monitoring/03-sdk-design/sensor-lifecycle-management/" data-link-title="感測器生命週期管理" data-link-desc="產品生命週期的五個階段各啟用什麼感測器 — feature flag 整合、取樣率動態調整、感測器開關的可觀察性">感測器生命週期管理</a>。</p>
<p><strong>SDK 未初始化</strong>：app 啟動後到 <code>monitor.init()</code> 完成之間的事件會被丟棄。如果 init 排在其他初始化邏輯之後，啟動階段的 crash 可能漏捕。商業 SDK（Sentry、Crashlytics）用 native crash handler 在 SDK 層之外攔截這類 crash，自架方案通常接受這個損失。</p>
<h3 id="環節二sdk-buffer-階段">環節二：SDK buffer 階段</h3>
<p>事件進入記憶體 buffer 後，等待 flush 觸發。Buffer 溢出和 app 強制終止是這段路徑上的兩個風險。</p>
<p><strong>FIFO 丟棄</strong>：記憶體 buffer 有容量上限（典型值 200-500 筆）。離線時間過長或事件產生速率過高時，buffer 滿了會丟棄最舊的事件。丟棄策略見<a href="/blog/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">離線 buffer 與重試</a>，優先級丟棄見 <a href="/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling 第一層</a>。</p>
<p><strong>App 強制終止</strong>：iOS 的 <code>kill</code>、Android 的 process death、Python 的 <code>SIGKILL</code> — 記憶體 buffer 中未 flush 的事件全部遺失。<a href="/blog/monitoring/03-sdk-design/batch-flush/" data-link-title="攢批送出策略" data-link-desc="flush interval / buffer size / flush on close 三個控制點決定事件何時離開 SDK — 平衡即時性和網路效率">攢批送出策略</a>的 close flush 嘗試在 app 正常退出時送出剩餘事件，但強制終止時連 close callback 都不會執行。</p>
<p><strong>動態取樣</strong>：收到 collector 的 HTTP 429（Too Many Requests，表示 collector 過載）後，SDK 自動降低取樣率（從 1.0 降到 0.5 → 0.1）。這是對 collector 過載的回饋反應 — 損失的事件量隨背壓程度增加。和靜態取樣的差異是動態取樣在正常情況下不生效，只在過載時啟用。</p>
<h3 id="環節三transport-階段">環節三：Transport 階段</h3>
<p>SDK flush 時透過 HTTP POST 送出 batch。網路故障和重試耗盡構成 transport 層的主要損失。</p>
<p><strong>HTTP 超時 / 連線失敗</strong>：collector 不可達時，batch 保留在 SDK buffer 等待下次 flush 重試。重試次數有上限（3 次），超過後丟棄 batch 並記錄 <code>sdk.flush.dropped</code> metric。重試策略見<a href="/blog/monitoring/03-sdk-design/batch-flush/" data-link-title="攢批送出策略" data-link-desc="flush interval / buffer size / flush on close 三個控制點決定事件何時離開 SDK — 平衡即時性和網路效率">攢批送出策略</a>。</p>
<p><strong>離線補發擁塞</strong>：離線恢復後，SDK 一次補發大量累積事件。如果補發速率過高（一批 500 筆 × 多個 SDK 同時恢復），collector 可能觸發背壓回 429，SDK 又進入動態降採樣 — 補發本身造成新的損失。<a href="/blog/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">離線 buffer 與重試</a>的分批補發（每批 50-100 筆、間隔 1-2 秒）用來避免這個問題。</p>
<h3 id="環節四collector-pipeline-階段">環節四：Collector pipeline 階段</h3>
<p>Collector 收到 HTTP request 後，事件進入處理鏈路。背壓、驗證拒絕和 pipeline 內部的 buffer 溢出都可能在這裡造成損失。</p>
<p><strong>Channel 背壓</strong>：Collector 內部用一個專屬的寫入 goroutine 搭配 Go channel 做序列化寫入（<a href="/blog/monitoring/04-collector/architecture/" data-link-title="Collector 架構" data-link-desc="HTTP endpoint → JSON Schema 驗證 → 儲存 → 查詢 → rule engine 的五段式處理鏈路">Collector 架構</a>的並發寫入策略段），channel 有固定容量。Channel 滿時 HTTP handler 回 429，事件被拒絕。SDK 收到 429 後保留事件在 buffer 等待重試，但如果 SDK buffer 也快滿，部分事件會被 FIFO 丟棄。這裡的損失是 SDK 層和 collector 層的連鎖反應 — collector 的背壓壓力最終由 SDK 的 buffer 承擔。</p>
<p><strong>Schema validation reject</strong>：事件格式不符合 JSON Schema 的事件被拒絕（400 或 207 中的 rejected 部分）。這是品質閘門而非容量限制 — 被拒絕的事件無論重試多少次都不會通過，SDK 應該清除這些事件並記錄 warning。問題在 SDK 端的事件建構邏輯（程式碼 bug），需要修 SDK 而非重試。</p>
<p><strong>429 後事件已回 202 但未寫入</strong>：collector 回了 202（已接受）但事件還在 channel buffer 中未寫入 storage 時，如果 collector crash 或被 SIGKILL，channel 中的事件遺失。這是「已承諾但未持久化」的窗口。<a href="/blog/monitoring/04-collector/container-deployment/" data-link-title="Container 部署設計" data-link-desc="Docker 部署 collector 的設計 — SQLite 在 overlay filesystem 的 I/O 考量、volume mount、graceful shutdown、資源限制">Container 部署設計</a>的 graceful shutdown 序列嘗試在 shutdown 時 flush pending writes，但非 graceful shutdown（OOMKill、硬體故障）無法保護。</p>
<h3 id="環節五storage-階段">環節五：Storage 階段</h3>
<p>事件從 channel 寫入 storage backend。寫入失敗和資料管理操作（downsample / purge）構成最後一段損失。</p>
<p><strong>SQLite <code>database is locked</code></strong>：busy timeout 到期後寫入失敗。Single-writer pattern 降低發生機率但不能完全消除 — downsample / purge job 執行期間持有 write lock，如果 job 跑太久（數秒以上），ingestion 的寫入可能逾時。</p>
<p><strong>磁碟空間不足</strong>：SQLite 寫入需要磁碟空間（WAL 檔案 + 主資料庫 + 臨時檔案）。磁碟滿時寫入失敗，事件遺失。保留策略的 purge job 負責控制磁碟使用量，但如果 purge 頻率低於寫入增長速率，磁碟可能在兩次 purge 之間被填滿。</p>
<p><strong>Downsample / purge 的設計內損失</strong>：保留策略到期的原始事件被刪除（purge），只保留聚合摘要（hourly_summary / daily_summary）。這是設計內的損失 — 原始事件的 stack trace、完整 JSON data 在 purge 後不可回復，只剩下計數。保留策略見<a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a>的分層保留段。</p>
<h2 id="設計內損失-vs-異常損失">設計內損失 vs 異常損失</h2>
<p>上述損失點可以分成兩類，處理方式根本不同。</p>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>損失點</th>
          <th>特徵</th>
          <th>處理方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>設計內</td>
          <td>靜態取樣、動態取樣、FIFO 丟棄、purge</td>
          <td>有意識的取捨、可預測的量</td>
          <td>在 config 中設定、用指標監控</td>
      </tr>
      <tr>
          <td>異常</td>
          <td>crash 丟 buffer、disk full、WAL 損壞</td>
          <td>非預期的故障、不可預測</td>
          <td>用告警偵測、用恢復機制應對</td>
      </tr>
      <tr>
          <td>品質閘門</td>
          <td>schema reject</td>
          <td>SDK 端 bug 導致、重試無效</td>
          <td>修 SDK 程式碼、不在 collector</td>
      </tr>
  </tbody>
</table>
<p>設計內損失的目標是讓損失量可控 — 取樣率設 0.1 代表預期丟 90%，FIFO buffer 容量 200 代表離線超過 20 分鐘（每分鐘 10 筆）後開始丟棄。這些數字是 config 參數，可以根據業務需求調整。</p>
<p>異常損失的目標是儘早偵測 — collector crash 後 channel 中有多少筆未寫入？磁碟使用率到多少該告警？下方的完整性指標段專門處理偵測異常損失的方法。</p>
<p>品質閘門的處理在 SDK 端而非 collector 端 — schema validation reject 的事件無論重試多少次都不會通過，問題在事件建構邏輯。具體的 reject 行為和回應格式見<a href="#%e7%92%b0%e7%af%80%e5%9b%9bcollector-pipeline-%e9%9a%8e%e6%ae%b5">環節四的 Schema validation reject 段</a>。</p>
<h2 id="監控損失本身的方法">監控損失本身的方法</h2>
<p>監控系統的完整性需要「監控自己的監控」— 用獨立的指標追蹤每個環節的進出量，損失量 = 進量 - 出量。</p>
<h3 id="sdk-端指標">SDK 端指標</h3>
<p>SDK 內部維護計數器，每次 flush 成功後一起送出（作為 metric 類事件）：</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>含義</th>
          <th>計算方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>sdk.events.produced</code></td>
          <td>事件產生總數（取樣前）</td>
          <td>每次 <code>monitor.event()</code> 調用 +1</td>
      </tr>
      <tr>
          <td><code>sdk.events.sampled</code></td>
          <td>取樣後保留的事件數</td>
          <td>通過取樣邏輯的事件 +1</td>
      </tr>
      <tr>
          <td><code>sdk.events.sent</code></td>
          <td>成功送出的事件數（收到 200/207 的 accepted）</td>
          <td>flush 成功後按 accepted 累加</td>
      </tr>
      <tr>
          <td><code>sdk.events.dropped</code></td>
          <td>被 FIFO 丟棄或重試耗盡的事件數</td>
          <td>每次丟棄 +1</td>
      </tr>
      <tr>
          <td><code>sdk.flush.failures</code></td>
          <td>flush 失敗次數（429 / 5xx / timeout）</td>
          <td>每次 flush 失敗 +1</td>
      </tr>
      <tr>
          <td><code>sdk.sampling.rate</code></td>
          <td>當前動態取樣率</td>
          <td>收到 429 後更新</td>
      </tr>
  </tbody>
</table>
<p><code>produced - sampled</code> = 取樣損失（設計內）。<code>sampled - sent - dropped</code> 如果不為零，代表有事件卡在 buffer 中尚未送出或未被計入任何分類。</p>
<h3 id="collector-端指標">Collector 端指標</h3>
<p>Collector 在 <code>/metrics</code> endpoint（或 health endpoint 的擴展欄位）暴露處理計數器：</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>含義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>collector.events.received</code></td>
          <td>收到的事件總數（HTTP handler 層計數）</td>
      </tr>
      <tr>
          <td><code>collector.events.rejected</code></td>
          <td>schema validation 拒絕的事件數</td>
      </tr>
      <tr>
          <td><code>collector.events.stored</code></td>
          <td>成功寫入 storage 的事件數</td>
      </tr>
      <tr>
          <td><code>collector.events.backpressure</code></td>
          <td>因 channel 滿回 429 的事件數</td>
      </tr>
      <tr>
          <td><code>collector.channel.depth</code></td>
          <td>當前 channel 中待寫入的事件數</td>
      </tr>
      <tr>
          <td><code>collector.storage.errors</code></td>
          <td>storage 寫入失敗的次數</td>
      </tr>
  </tbody>
</table>
<p><code>received - rejected - stored - backpressure</code> 如果不為零，代表有事件在 pipeline 中遺失（channel buffer 中的事件在 crash 時丟失就會造成這個差距）。</p>
<h3 id="端到端比對">端到端比對</h3>
<p>SDK 的 <code>sent</code> 和 collector 的 <code>received</code> 之間的差距是 transport 層的損失 — 網路丟包、中間件攔截（reverse proxy 的 body size limit）或 collector 重啟期間的連線失敗。</p>
<p>這個比對在自用場景下用手動 spot check 就夠（SDK log 的 sent count vs collector dashboard 的 received count）。小型以上規模需要自動化：一個定期 job 比對兩邊的計數器，差距超過閾值時告警。</p>
<h3 id="損失率的可接受範圍">損失率的可接受範圍</h3>
<table>
  <thead>
      <tr>
          <th>規模</th>
          <th>event 類損失率</th>
          <th>error 類損失率</th>
          <th>監控粒度</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自用</td>
          <td>&lt; 10%</td>
          <td>&lt; 1%</td>
          <td>手動 spot check</td>
      </tr>
      <tr>
          <td>小型團隊</td>
          <td>&lt; 5%</td>
          <td>&lt; 0.5%</td>
          <td>每日自動比對</td>
      </tr>
      <tr>
          <td>中型以上</td>
          <td>&lt; 1%</td>
          <td>&lt; 0.1%</td>
          <td>即時 dashboard + 告警</td>
      </tr>
  </tbody>
</table>
<p>閾值的推導邏輯：event 類的損失影響統計精度 — 取樣率 0.9 加上 transport 和 collector 層的少量損失，自用場景合計 &lt; 10% 是合理的上限；funnel 分析用取樣校正（除以取樣率）仍然有效。Error 類的損失直接影響 bug 發現速度 — 容忍度比 event 低一個數量級。中型以上規模的 &lt; 1% / &lt; 0.1% 接近商業方案（Sentry / Datadog）的 SLA 水準。</p>
<p><a href="/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling</a> 的 error 快通道設計就是基於這個優先級差異。</p>
<h2 id="被自己的-sdk-ddos">被自己的 SDK DDoS</h2>
<p>「SDK 產生的流量壓垮自己的 collector」是自架監控系統最常見的可靠性事故。來源是自家 SDK 的異常行為或正常行為在特定條件下的放大效應 — 內部流量失控，而非外部攻擊。外部偽造流量的防護見 <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>。</p>
<p>本段按觸發場景分類（SDK bug / 部署推送 / 使用者暴增），和 <a href="/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling</a> 的四層防線（SDK 端 / collector 單機 / 水平擴展 / queue 解耦）是不同切面。四層防線按防護位置劃分、說明機制怎麼做；本段按場景劃分、說明什麼時候哪些機制會被觸發。</p>
<h3 id="sdk-bug事件風暴">SDK bug：事件風暴</h3>
<p>SDK 程式碼 bug 導致事件無限迴圈 — 常見於事件處理器內再次觸發事件（error handler 中呼叫 <code>monitor.event()</code> 又觸發 error），或 UI 事件綁定錯誤導致每個 frame 產生一筆事件（60 fps = 每秒 60 筆）。</p>
<p><strong>損失路徑</strong>：事件風暴首先填滿 SDK buffer → 觸發高頻 flush → collector 收到大量 request → channel 滿觸發 429 → SDK 動態降採樣。如果 SDK 的動態降採樣邏輯本身也有 bug（降到 0.1 後不再降），collector 仍然會持續承壓。</p>
<p><strong>防護層級</strong>：</p>
<p>SDK 端 — 事件產生速率上限。SDK 內部維護每秒事件計數器，超過閾值（例如 100 events/sec）後的事件直接丟棄，不進 buffer。這個上限獨立於取樣和背壓機制，是防止 SDK 自身 bug 的最後一道防線。</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">// SDK 端的 rate limiter（偽碼，各語言實作不同）
</span></span><span class="line"><span class="ln">2</span><span class="cl">count = atomicIncrement(eventCounter)
</span></span><span class="line"><span class="ln">3</span><span class="cl">if count &gt; maxEventsPerSecond:
</span></span><span class="line"><span class="ln">4</span><span class="cl">    atomicIncrement(droppedCounter)
</span></span><span class="line"><span class="ln">5</span><span class="cl">    return  // 不進 buffer</span></span></code></pre></div><p>Collector 端 — per-key rate limit。每個 API key（或 source.app）的請求速率獨立限制。一個失控的 SDK 被限速時，其他 SDK 的事件不受影響。這和 <a href="/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling</a> 的 per-SDK rate limiting 是同一個機制。</p>
<p>Collector 端 — circuit breaker。如果某個 API key 的 429 回應次數在短時間內超過閾值，collector 暫時拒絕該 key 的所有請求（回 503），不再逐筆檢查 rate limit。冷卻期過後自動恢復。這降低了 rate limit 本身的 CPU 開銷 — 高頻 429 回應也有成本。閾值需高於正常 burst 的 per-key 429 頻率 — 如果正常 flush 在 burst 時每分鐘最多觸發 N 次 429，circuit breaker 閾值設為 5N-10N 避免誤觸。具體數字（例如 50 次/分鐘、5 分鐘冷卻）依部署規模調整。</p>
<h3 id="部署推送補發風暴">部署推送：補發風暴</h3>
<p>100 台機器同時重啟（rolling deploy），每台機器的 SDK 在啟動時：</p>
<ol>
<li>讀取本地 persistence 中的離線事件</li>
<li>初始化後立即 flush 離線事件 + 新的 lifecycle 事件</li>
</ol>
<p>100 個 SDK 在幾秒內同時發起離線補發 + 正常 flush，collector 瞬間承受 100 倍的正常流量。</p>
<p><strong>防護方式</strong>：init jitter — SDK 初始化後不立即 flush，而是等待一個隨機延遲（0 到 flush_interval 之間的均勻分佈）。100 個 SDK 的首次 flush 分散在 0-30 秒內，流量從一個尖峰變成斜坡。</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="kn">import</span> <span class="nn">random</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">initial_delay</span> <span class="o">=</span> <span class="n">random</span><span class="o">.</span><span class="n">uniform</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">flush_interval_seconds</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 第一次 flush 延遲 initial_delay 秒，後續按正常 interval</span></span></span></code></pre></div><p>離線補發也加 jitter — 每批補發之間的間隔從固定的 1 秒改為 1-3 秒的隨機值。100 個 SDK 的補發批次在時間軸上交錯，避免所有 SDK 以相同節奏同時送出。</p>
<h3 id="使用者行為高峰同時在線暴增">使用者行為高峰：同時在線暴增</h3>
<p>行銷活動、媒體報導、季節性高峰 — 同時在線使用者從 100 人暴增到 10,000 人。每個使用者的 SDK 正常運作，但總量超出 collector 的處理能力。</p>
<p>這個場景和 SDK bug 的差異：每個 SDK 的行為完全正常，問題在總量。Per-key rate limit 不會觸發（每個 SDK 的速率在正常範圍），需要的是全域流量控制。</p>
<p><strong>防護方式</strong>：Collector 端的全域 channel 背壓（<a href="/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling 第二層</a>）是第一道防線 — channel 滿時所有 SDK 收到 429，各自動態降採樣。如果動態降採樣後流量仍然過大，水平擴展（多 collector + load balancer）或 queue 解耦是解法。</p>
<p>行銷活動的可預測性是優勢 — 活動日期已知，可以提前擴展 collector 容量（加機器或調高 channel 容量）。突發的媒體報導則依賴動態降採樣和背壓的自動調節。</p>
<h3 id="三種場景的防護對照">三種場景的防護對照</h3>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>流量特徵</th>
          <th>首要防護</th>
          <th>次要防護</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SDK bug</td>
          <td>單 SDK 異常高頻</td>
          <td>SDK 端 rate limit + per-key limit</td>
          <td>Circuit breaker</td>
      </tr>
      <tr>
          <td>部署推送</td>
          <td>多 SDK 同時突發</td>
          <td>Init jitter + 補發 jitter</td>
          <td>Channel 背壓</td>
      </tr>
      <tr>
          <td>使用者暴增</td>
          <td>全域持續高量</td>
          <td>動態降採樣 + channel 背壓</td>
          <td>水平擴展 / queue 解耦</td>
      </tr>
  </tbody>
</table>
<h2 id="資料恢復-vs-接受損失">資料恢復 vs 接受損失</h2>
<p>每個損失點都可以投入工程努力降低損失量。問題是恢復的工程成本是否值得 — 監控資料不是交易紀錄，恢復的價值取決於損失的事件類型和數量。</p>
<h3 id="值得恢復的場景">值得恢復的場景</h3>
<p><strong>Error 事件</strong>：每筆 error 都可能對應一個需要修的 bug。Error 的損失代表 bug 可能更晚被發現、在更多使用者身上發生後才被注意到。值得投入本地 persistence、優先級丟棄（error 最後丟）、error 快通道等機制降低損失。</p>
<p><strong>Lifecycle 事件</strong>：session 邊界（session.begin / session.end）是 cohort 分析和 session replay 的基礎。丟失 session 邊界會讓整個 session 的事件無法正確歸屬。Lifecycle 事件量低（每 session 幾筆），保留成本小、損失影響大。</p>
<h3 id="接受損失的場景">接受損失的場景</h3>
<p><strong>高頻 metric 事件</strong>：render.frame_time 每秒 60 筆，丟幾筆對趨勢分析的影響在統計誤差範圍內。聚合前移（SDK 端每 5 秒送一筆 summary）比逐筆保留更有效率。</p>
<p><strong>行為 event 事件</strong>：button.click、page.view 在取樣後丟幾筆，funnel 的轉換率計算用取樣校正（除以取樣率）仍然有效。單筆行為事件的 debug 價值低 — 知道某使用者點了某按鈕通常不影響決策。</p>
<p><strong>超過保留期的原始事件</strong>：purge 後只剩聚合摘要。如果分析需求發現需要更長的原始事件保留期，調整 retention config，不要嘗試從聚合摘要「恢復」原始事件 — 那是不可能的。</p>
<h3 id="恢復成本的判斷">恢復成本的判斷</h3>
<p>本地 persistence（SDK 端把 buffer 寫到檔案系統）的實作成本和收益：</p>
<table>
  <thead>
      <tr>
          <th>因素</th>
          <th>記憶體 FIFO（簡單）</th>
          <th>本地 persistence（完整）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>實作成本</td>
          <td>array + 容量檢查</td>
          <td>檔案讀寫 + 並發安全 + 容量管理 + 去重</td>
      </tr>
      <tr>
          <td>保護範圍</td>
          <td>短暫離線（buffer 容量內）</td>
          <td>長時間離線（本地儲存容量內）</td>
      </tr>
      <tr>
          <td>不保護</td>
          <td>app 強制終止</td>
          <td>app 強制終止（寫入中的事件仍然遺失）</td>
      </tr>
      <tr>
          <td>適用場景</td>
          <td>自用工具、SDK 初期版本</td>
          <td>行動 app、離線場景頻繁的使用環境</td>
      </tr>
  </tbody>
</table>
<p>MVP 階段用記憶體 FIFO。本地 persistence 作為第二階段功能，在離線損失率超出可接受範圍時投入。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>SDK 端的離線保護 → <a href="/blog/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">離線 buffer 與重試</a></li>
<li>Collector 端的流量防護 → <a href="/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling</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>Container 環境的 graceful shutdown → <a href="/blog/monitoring/04-collector/container-deployment/" data-link-title="Container 部署設計" data-link-desc="Docker 部署 collector 的設計 — SQLite 在 overlay filesystem 的 I/O 考量、volume mount、graceful shutdown、資源限制">Container 部署設計</a></li>
<li>保留策略和降採樣 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a></li>
<li>SDK 認證和偽造流量防護 → <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>
</ul>
]]></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><item><title>監控實務指南</title><link>https://tarrragon.github.io/blog/monitoring/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/</guid><description>&lt;p>監控教材的核心目標是教讀者理解「使用者的裝置上發生了什麼事」。開發者不在使用者旁邊，需要系統性地收集行為事件、攔截錯誤、量測效能、追蹤生命週期 — 這四類資訊構成客戶端可觀測性的完整圖像。&lt;/p>
&lt;h2 id="跟-backend-可觀測性的關係">跟 Backend 可觀測性的關係&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend 模組四：可觀測性平台&lt;/a> 聚焦 server-side — Prometheus metrics、OpenTelemetry tracing、log aggregation、alert routing。那是「伺服器怎麼知道自己出問題」。&lt;/p>
&lt;p>本系列聚焦非 server 端運行時 — mobile app、web 頁面、本機腳本（CLI / Hook）、本機服務。這是「開發者怎麼知道使用者端出問題」。&lt;/p>
&lt;p>兩者的交叉點是 &lt;strong>事件格式&lt;/strong> 和 &lt;strong>transport&lt;/strong>。Server-side 用 OTLP（OpenTelemetry Protocol）；本系列用 HTTP POST JSON — 更簡單、無依賴、適合小規模自架。大規模時可橋接到 OTLP。&lt;/p>
&lt;h2 id="跟-testing-的關係">跟 Testing 的關係&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/testing/" data-link-title="開發測試實務指南" data-link-desc="整理測試策略分層、協議整合驗證、客戶端可觀測性、錯誤收集與自動化驗證 — 從「測試全過但實機全壞」的結構性盲區出發，建立可操作的品質驗證體系">開發測試模組二：客戶端可觀測性&lt;/a> 聚焦「開發期的 log 設計」— 連線生命週期 log、protocol 訊息 log、功能規格中的 log 點定義。那是「怎麼在開發時就設計好 log」。&lt;/p>
&lt;p>本系列聚焦「log 收集到之後的完整鏈路」— SDK 怎麼埋點、事件怎麼送、collector 怎麼收、資料怎麼查、規則怎麼觸發。Testing 模組二是設計端，本系列是基礎設施端。&lt;/p>
&lt;h2 id="跟-infra-可觀測性的關係">跟 Infra 可觀測性的關係&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">Infra 模組六：可觀測性與 log&lt;/a> 聚焦基礎設施層 — log group、CloudWatch metric、alarm 跟資源同生命週期的 IaC 管理。那是「基礎設施怎麼知道自己出問題」。本系列跟 Backend 可觀測性、Infra 可觀測性三者的分界是觀測對象：infra 觀測資源健康（CPU、磁碟、網路連通）、backend 觀測服務行為（延遲、錯誤率、trace）、本系列觀測客戶端行為（使用者操作、前端錯誤、效能指標）。事故排查時三者合流。&lt;/p>
&lt;h2 id="跟-dotfile-的關係">跟 Dotfile 的關係&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/linux/dotfile/" data-link-title="Dotfile 工作環境配置指南" data-link-desc="個人開發環境的配置管理 — dotfile 結構設計、同步策略、shell 與終端機配置、平鋪式視窗管理、桌面客製化，從個人工具鏈延伸到團隊環境標準化">Dotfile 工作環境配置指南&lt;/a> 聚焦個人開發環境的配置管理。Monitoring 系列有獨立的 hands-on 專案做實測，Dotfile 系列也會搭配 VM 實測專案做 Hyprland 桌面配置的驗證——兩者的教材 + 實測專案模式平行。&lt;/p>
&lt;p>斷網環境（air-gapped）裡這三層都要 self-hosted——infra 層用 Prometheus + Grafana、backend 層用自架的 trace/log collector、本系列的 SDK 和 Collector 也要部署在內網。斷網環境的 infra 層監控設定見&lt;a href="https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-monitoring/" data-link-title="斷網環境的監控與可觀測性" data-link-desc="Self-hosted 監控（Prometheus &amp;#43; Grafana）、離線 log 收集（Loki / ELK）、不能 phone home 的告警、NTP 時間同步">斷網環境的監控與可觀測性&lt;/a>。&lt;/p>
&lt;h2 id="教學範圍">教學範圍&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>放在本系列&lt;/th>
 &lt;th>放在其他系列&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>監控心智模型（四類事件分類與收集策略）&lt;/td>
 &lt;td>server-side observability（放 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend 04&lt;/a>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨平台 SDK 設計（JS / Flutter / Python）&lt;/td>
 &lt;td>特定語言的 error handling（放語言教材）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>自架 collector（Go、JSONL、rule engine）&lt;/td>
 &lt;td>商業 APM 管理後台操作&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Log schema 與 transport 規格&lt;/td>
 &lt;td>分散式 tracing（放 Backend 04）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>商業方案對照（Sentry / Crashlytics / Datadog RUM）&lt;/td>
 &lt;td>商業方案的付費方案比較&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>本機腳本監控（Python Hook / CLI 工具）&lt;/td>
 &lt;td>server daemon 監控（放 Backend 05 部署平台）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Rule engine（條件觸發 → 自動 issue / alert）&lt;/td>
 &lt;td>Incident response 流程（放 &lt;a href="https://tarrragon.github.io/blog/backend/08-incident-response/" data-link-title="模組八：事故處理與復盤" data-link-desc="用 IR 領域詞彙建問題節點、以服務級案例庫累積事故脈絡，先建概念與案例庫再進實作交接">Backend 08&lt;/a>）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="教學模組">教學模組&lt;/h2>
&lt;h3 id="模組一監控心智模型">模組一：監控心智模型&lt;/h3>
&lt;p>回答「要收集什麼、為什麼」。四類事件各自解答的問題：&lt;/p></description><content:encoded><![CDATA[<p>監控教材的核心目標是教讀者理解「使用者的裝置上發生了什麼事」。開發者不在使用者旁邊，需要系統性地收集行為事件、攔截錯誤、量測效能、追蹤生命週期 — 這四類資訊構成客戶端可觀測性的完整圖像。</p>
<h2 id="跟-backend-可觀測性的關係">跟 Backend 可觀測性的關係</h2>
<p><a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend 模組四：可觀測性平台</a> 聚焦 server-side — Prometheus metrics、OpenTelemetry tracing、log aggregation、alert routing。那是「伺服器怎麼知道自己出問題」。</p>
<p>本系列聚焦非 server 端運行時 — mobile app、web 頁面、本機腳本（CLI / Hook）、本機服務。這是「開發者怎麼知道使用者端出問題」。</p>
<p>兩者的交叉點是 <strong>事件格式</strong> 和 <strong>transport</strong>。Server-side 用 OTLP（OpenTelemetry Protocol）；本系列用 HTTP POST JSON — 更簡單、無依賴、適合小規模自架。大規模時可橋接到 OTLP。</p>
<h2 id="跟-testing-的關係">跟 Testing 的關係</h2>
<p><a href="/blog/testing/" data-link-title="開發測試實務指南" data-link-desc="整理測試策略分層、協議整合驗證、客戶端可觀測性、錯誤收集與自動化驗證 — 從「測試全過但實機全壞」的結構性盲區出發，建立可操作的品質驗證體系">開發測試模組二：客戶端可觀測性</a> 聚焦「開發期的 log 設計」— 連線生命週期 log、protocol 訊息 log、功能規格中的 log 點定義。那是「怎麼在開發時就設計好 log」。</p>
<p>本系列聚焦「log 收集到之後的完整鏈路」— SDK 怎麼埋點、事件怎麼送、collector 怎麼收、資料怎麼查、規則怎麼觸發。Testing 模組二是設計端，本系列是基礎設施端。</p>
<h2 id="跟-infra-可觀測性的關係">跟 Infra 可觀測性的關係</h2>
<p><a href="/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">Infra 模組六：可觀測性與 log</a> 聚焦基礎設施層 — log group、CloudWatch metric、alarm 跟資源同生命週期的 IaC 管理。那是「基礎設施怎麼知道自己出問題」。本系列跟 Backend 可觀測性、Infra 可觀測性三者的分界是觀測對象：infra 觀測資源健康（CPU、磁碟、網路連通）、backend 觀測服務行為（延遲、錯誤率、trace）、本系列觀測客戶端行為（使用者操作、前端錯誤、效能指標）。事故排查時三者合流。</p>
<h2 id="跟-dotfile-的關係">跟 Dotfile 的關係</h2>
<p><a href="/blog/linux/dotfile/" data-link-title="Dotfile 工作環境配置指南" data-link-desc="個人開發環境的配置管理 — dotfile 結構設計、同步策略、shell 與終端機配置、平鋪式視窗管理、桌面客製化，從個人工具鏈延伸到團隊環境標準化">Dotfile 工作環境配置指南</a> 聚焦個人開發環境的配置管理。Monitoring 系列有獨立的 hands-on 專案做實測，Dotfile 系列也會搭配 VM 實測專案做 Hyprland 桌面配置的驗證——兩者的教材 + 實測專案模式平行。</p>
<p>斷網環境（air-gapped）裡這三層都要 self-hosted——infra 層用 Prometheus + Grafana、backend 層用自架的 trace/log collector、本系列的 SDK 和 Collector 也要部署在內網。斷網環境的 infra 層監控設定見<a href="/blog/infra/air-gapped/air-gapped-monitoring/" data-link-title="斷網環境的監控與可觀測性" data-link-desc="Self-hosted 監控（Prometheus &#43; Grafana）、離線 log 收集（Loki / ELK）、不能 phone home 的告警、NTP 時間同步">斷網環境的監控與可觀測性</a>。</p>
<h2 id="教學範圍">教學範圍</h2>
<table>
  <thead>
      <tr>
          <th>放在本系列</th>
          <th>放在其他系列</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>監控心智模型（四類事件分類與收集策略）</td>
          <td>server-side observability（放 <a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend 04</a>）</td>
      </tr>
      <tr>
          <td>跨平台 SDK 設計（JS / Flutter / Python）</td>
          <td>特定語言的 error handling（放語言教材）</td>
      </tr>
      <tr>
          <td>自架 collector（Go、JSONL、rule engine）</td>
          <td>商業 APM 管理後台操作</td>
      </tr>
      <tr>
          <td>Log schema 與 transport 規格</td>
          <td>分散式 tracing（放 Backend 04）</td>
      </tr>
      <tr>
          <td>商業方案對照（Sentry / Crashlytics / Datadog RUM）</td>
          <td>商業方案的付費方案比較</td>
      </tr>
      <tr>
          <td>本機腳本監控（Python Hook / CLI 工具）</td>
          <td>server daemon 監控（放 Backend 05 部署平台）</td>
      </tr>
      <tr>
          <td>Rule engine（條件觸發 → 自動 issue / alert）</td>
          <td>Incident response 流程（放 <a href="/blog/backend/08-incident-response/" data-link-title="模組八：事故處理與復盤" data-link-desc="用 IR 領域詞彙建問題節點、以服務級案例庫累積事故脈絡，先建概念與案例庫再進實作交接">Backend 08</a>）</td>
      </tr>
  </tbody>
</table>
<h2 id="教學模組">教學模組</h2>
<h3 id="模組一監控心智模型">模組一：監控心智模型</h3>
<p>回答「要收集什麼、為什麼」。四類事件各自解答的問題：</p>
<table>
  <thead>
      <tr>
          <th>事件類型</th>
          <th>回答什麼問題</th>
          <th>範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>event</code></td>
          <td>使用者做了什麼？</td>
          <td>button.click、page.view、hook.run、qr.scan</td>
      </tr>
      <tr>
          <td><code>error</code></td>
          <td>哪裡壞了？</td>
          <td>uncaught exception、network error、hook failure</td>
      </tr>
      <tr>
          <td><code>metric</code></td>
          <td>有多快 / 多慢？</td>
          <td>response_time、render_duration、hook_duration_ms</td>
      </tr>
      <tr>
          <td><code>lifecycle</code></td>
          <td>系統的狀態轉換？</td>
          <td>app.start、session.begin、ws.connect、hook.init</td>
      </tr>
  </tbody>
</table>
<p>四類不是互斥的 — 一個 hook 執行可以同時產生 <code>lifecycle</code>（hook.start）、<code>metric</code>（duration）、<code>error</code>（如果失敗），和 <code>event</code>（hook.complete）。分類的價值是讓查詢和 rule engine 能按類型過濾。</p>
<p><strong>商業方案如何對應</strong>：</p>
<table>
  <thead>
      <tr>
          <th>商業方案</th>
          <th>對應的事件類型</th>
          <th>額外能力</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Sentry</td>
          <td>error + metric</td>
          <td>stack trace 去重、release tracking</td>
      </tr>
      <tr>
          <td>Firebase Crashlytics</td>
          <td>error</td>
          <td>crash-free rate、ANR 偵測</td>
      </tr>
      <tr>
          <td>Firebase Analytics</td>
          <td>event + lifecycle</td>
          <td>funnel、retention、user property</td>
      </tr>
      <tr>
          <td>Datadog RUM</td>
          <td>event + error + metric</td>
          <td>session replay、waterfall、core vitals</td>
      </tr>
      <tr>
          <td>Mixpanel / Amplitude</td>
          <td>event</td>
          <td>funnel、cohort、A/B test attribution</td>
      </tr>
  </tbody>
</table>
<p>自架方案覆蓋四類事件的收集和儲存；商業方案在此基礎上加 dashboard、去重、alerting、session replay 等進階功能。理解四類事件的分類後，商業方案的功能差異就是「在哪類事件上做了什麼加值」。</p>
<h3 id="模組二log-schema-設計">模組二：Log Schema 設計</h3>
<p>回答「事件長什麼樣」。跨平台統一事件格式、欄位設計、版本演進策略。</p>
<p>核心格式（<code>schema/event.schema.json</code>）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nt">&#34;v&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;error&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nt">&#34;timestamp&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-06-19T20:00:00Z&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nt">&#34;source&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;sdk&#34;</span><span class="p">:</span> <span class="s2">&#34;python&#34;</span><span class="p">,</span> <span class="nt">&#34;platform&#34;</span><span class="p">:</span> <span class="s2">&#34;macos&#34;</span><span class="p">,</span> <span class="nt">&#34;app&#34;</span><span class="p">:</span> <span class="s2">&#34;claude-hooks&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;hook.failure&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="nt">&#34;level&#34;</span><span class="p">:</span> <span class="s2">&#34;error&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nt">&#34;data&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;hook&#34;</span><span class="p">:</span> <span class="s2">&#34;branch-status-reminder&#34;</span><span class="p">,</span> <span class="nt">&#34;duration_ms&#34;</span><span class="p">:</span> <span class="mi">42</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="nt">&#34;error&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;message&#34;</span><span class="p">:</span> <span class="s2">&#34;FileNotFoundError: ...&#34;</span><span class="p">,</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;FileNotFoundError&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>設計原則：</p>
<ol>
<li><strong><code>source</code> 標明來源</strong> — 收到事件就知道是哪個 SDK、哪個平台、哪個 app</li>
<li><strong><code>data</code> 是自由欄位</strong> — 不同場景的附帶資料差異太大，用結構化 JSON 而非固定欄位</li>
<li><strong><code>v</code> 做版本演進</strong> — Schema 改版時 collector 靠版本號決定解析方式</li>
<li><strong>四類 <code>type</code></strong> — 查詢和 rule engine 的第一個過濾維度</li>
</ol>
<blockquote>
<p>對應 repo：<a href="https://github.com/tarrragon/monitor">tarrragon/monitor</a> 的 <code>schema/event.schema.json</code> 是 SOT</p></blockquote>
<h3 id="模組三sdk-設計模式">模組三：SDK 設計模式</h3>
<p>回答「怎麼在各平台埋點」。三個 SDK 共用同一套事件格式，但攔截機制不同：</p>
<table>
  <thead>
      <tr>
          <th>平台</th>
          <th>自動攔截</th>
          <th>手動上報</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>JS/TS</td>
          <td><code>window.onerror</code>、<code>unhandledrejection</code></td>
          <td><code>monitor.event('name', {})</code></td>
      </tr>
      <tr>
          <td>Flutter</td>
          <td><code>FlutterError.onError</code>、<code>PlatformDispatcher</code></td>
          <td><code>monitor.event('name', {})</code></td>
      </tr>
      <tr>
          <td>Python</td>
          <td><code>sys.excepthook</code>、<code>atexit</code></td>
          <td><code>monitor.event('name', {})</code></td>
      </tr>
  </tbody>
</table>
<p>三個 SDK 的公開 API 設計應保持一致（同名方法、同參數順序），讓跨平台開發者不需重新學習。</p>
<h3 id="模組四collector-設計">模組四：Collector 設計</h3>
<p>回答「收到的事件怎麼處理」。Go 單一 binary，零外部依賴。</p>
<p>職責鏈：收（HTTP endpoint）→ 驗（JSON Schema）→ 存（JSONL 檔案）→ 查（CLI 查詢）→ 觸發（rule engine）。</p>
<p>自用場景的 collector 跟 production 級 observability 平台的差異：沒有 dashboard（用 grep / jq）、沒有 alerting（用 rule engine + 腳本）、沒有 HA（單機就夠）。這些是刻意的設計選擇——零依賴、零運維、grep 友好。</p>
<p>從 SDK 到 storage 的每個環節都有丟失事件的可能。<a href="/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性</a>整理了整條鏈路的損失地圖、控制策略、完整性指標，以及被自己 SDK DDoS 時的防護方式。<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>把相同根因的 error 歸組，讓 dashboard 從逐筆列表演進到可管理的 issue 列表。</p>
<h3 id="模組五平台適配">模組五：平台適配</h3>
<p>回答「各平台有什麼特殊考量」。JS 的 CORS 限制、Flutter 的 isolate 安全、Python 的 GIL 與 atexit、Go 的 graceful shutdown。</p>
<h3 id="模組六商業方案對照">模組六：商業方案對照</h3>
<p>回答「什麼時候該從自架切換到商業方案」。判斷標準：</p>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>自架</th>
          <th>商業方案</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>使用者 = 開發者自己</td>
          <td>適合</td>
          <td>過度</td>
      </tr>
      <tr>
          <td>使用者 &lt; 100 人、同區網</td>
          <td>適合</td>
          <td>可考慮免費方案</td>
      </tr>
      <tr>
          <td>使用者 &gt; 1000 人、外部網路</td>
          <td>維護成本高</td>
          <td>適合</td>
      </tr>
      <tr>
          <td>需要 session replay / funnel</td>
          <td>自建成本高</td>
          <td>適合</td>
      </tr>
      <tr>
          <td>需要合規稽核（SOC 2 / GDPR）</td>
          <td>自建困難</td>
          <td>適合（已認證）</td>
      </tr>
  </tbody>
</table>
<p>跟模組八的關係：模組六比較「自架 vs 商業」的功能和成本；模組八把行為資料視為商業資產，討論精準行銷、推薦系統、A/B test attribution — 這些是商業方案的核心賣點，也是自架方案最難自建的部分。</p>
<h3 id="模組七資安與隱私">模組七：資安與隱私</h3>
<p>回答「蒐集的資料本身就是風險資產，怎麼保護」。同一份監控資料在不同角色眼中有不同身份：</p>
<table>
  <thead>
      <tr>
          <th>角色</th>
          <th>看到的是</th>
          <th>關心的問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>開發者</td>
          <td>debug 資訊</td>
          <td>錯誤在哪、使用者做了什麼</td>
      </tr>
      <tr>
          <td>安全團隊</td>
          <td>風險資產</td>
          <td>含 secret 嗎、被入侵會洩漏什麼</td>
      </tr>
      <tr>
          <td>法務團隊</td>
          <td>合規負債</td>
          <td>蒐集合法嗎、保留多久、跨境嗎</td>
      </tr>
      <tr>
          <td>行銷團隊</td>
          <td>商業原料</td>
          <td>能做 funnel 嗎、能投廣告嗎</td>
      </tr>
  </tbody>
</table>
<p><strong>三層防護設計</strong>（影響 SDK 和 collector 的實作）：</p>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>在哪裡做</th>
          <th>做什麼</th>
          <th>影響 monitor repo 哪裡</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SDK 端 redaction</td>
          <td>sdk-js / sdk-flutter / sdk-python</td>
          <td>送出前自動遮蔽已知 secret pattern（API key / password / token / file path 中的 username）</td>
          <td>SDK 的 <code>redact()</code> helper + 預設 redaction rule</td>
      </tr>
      <tr>
          <td>Transport 加密</td>
          <td>SDK → collector</td>
          <td>HTTPS 或至少 basic auth（即使同區網）</td>
          <td>transport 規格 + collector TLS 設定</td>
      </tr>
      <tr>
          <td>Collector 端 access control</td>
          <td>collector</td>
          <td>儲存加密 at rest、查詢需認證、access log 記錄誰查了什麼</td>
          <td>collector 的 auth middleware + 加密儲存</td>
      </tr>
  </tbody>
</table>
<p><strong>去識別化策略</strong>：</p>
<table>
  <thead>
      <tr>
          <th>資料類型</th>
          <th>去識別方法</th>
          <th>時機</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>IP 地址</td>
          <td>截斷最後一段（192.168.1.x）</td>
          <td>SDK 端或 collector 收到時</td>
      </tr>
      <tr>
          <td>User agent</td>
          <td>保留 OS + browser 版本，去除 fingerprint 細節</td>
          <td>collector 收到時</td>
      </tr>
      <tr>
          <td>自由欄位 <code>data</code></td>
          <td>regex 掃描已知 secret pattern，替換為 <code>[REDACTED]</code></td>
          <td>SDK 端送出前</td>
      </tr>
      <tr>
          <td>Stack trace</td>
          <td>去除絕對路徑中的 username</td>
          <td>SDK 端送出前</td>
      </tr>
      <tr>
          <td>Session ID</td>
          <td>不跟真實使用者身份綁定（匿名 UUID）</td>
          <td>SDK 初始化時</td>
      </tr>
  </tbody>
</table>
<p>跟 <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">Backend 07 資安與資料保護</a> 的關係：Backend 07 聚焦 server-side 的權限、秘密管理、稽核追蹤；本模組聚焦「蒐集來的監控資料本身」的保護。交叉點是 <a href="/blog/backend/knowledge-cards/secret-management/" data-link-title="Secret Management" data-link-desc="說明 token、key、password 與憑證如何保存、輪替與撤銷">Secret Management</a> — 監控資料裡意外包含 secret 時，去識別化機制需要知道什麼 pattern 算 secret。</p>
<p>Client-side SDK 的 credential 嵌在使用者手上的程式碼中（JS bundle / APK / Python script），必然可被提取 — 這是 architecture 限制而非 implementation 問題。<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> 處理「credential 已暴露」前提下的多層緩解策略。</p>
<h3 id="模組八行為資料的商業利用">模組八：行為資料的商業利用</h3>
<p>回答「蒐集到的行為資料除了 debug，還能做什麼」。這是監控體系從「開發工具」翻轉成「商業資產」的轉折點。</p>
<p><strong>前提：模組七的去識別化是本模組的入場條件。</strong> 沒做好去識別化就做精準行銷 = 法律風險。本模組假設資料已經過去識別化處理。</p>
<p><strong>行為資料的商業價值鏈</strong>：</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">蒐集（SDK 埋點）
</span></span><span class="line"><span class="ln">2</span><span class="cl">  → 清洗（去識別 + 去噪）
</span></span><span class="line"><span class="ln">3</span><span class="cl">    → 分析（funnel / cohort / attribution）
</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">        → 驗證（A/B test → 回到蒐集）</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>分析類型</th>
          <th>回答什麼問題</th>
          <th>需要的事件</th>
          <th>商業方案</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Funnel analysis</td>
          <td>使用者在哪一步流失？</td>
          <td><code>event</code>（page.view / button.click / checkout.complete）</td>
          <td>Mixpanel / Amplitude / GA4</td>
      </tr>
      <tr>
          <td>Cohort analysis</td>
          <td>不同族群的留存率差異？</td>
          <td><code>event</code> + <code>lifecycle</code>（session.begin 時間）</td>
          <td>Mixpanel / Amplitude</td>
      </tr>
      <tr>
          <td>Attribution</td>
          <td>使用者從哪來？哪個廣告帶來轉換？</td>
          <td><code>event</code>（install / first_open / conversion）</td>
          <td>Adjust / AppsFlyer / GA4</td>
      </tr>
      <tr>
          <td>A/B test</td>
          <td>哪個版本的按鈕轉換率更高？</td>
          <td><code>event</code>（variant_shown / conversion）</td>
          <td>Optimizely / LaunchDarkly / 自建</td>
      </tr>
      <tr>
          <td>推薦系統</td>
          <td>這個使用者可能對什麼感興趣？</td>
          <td><code>event</code>（view / click / purchase 歷史）</td>
          <td>自建 / AWS Personalize</td>
      </tr>
      <tr>
          <td>RFM 分群</td>
          <td>誰是高價值客戶？誰快流失？</td>
          <td><code>event</code>（purchase 頻率 / 金額 / 最近一次）</td>
          <td>自建 / CRM 工具</td>
      </tr>
  </tbody>
</table>
<p><strong>跟監控的邊界</strong>：</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>監控（模組一~六）</th>
          <th>商業利用（本模組）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>看的事件</td>
          <td>全部四類（error 為主）</td>
          <td>主要 <code>event</code> 類</td>
      </tr>
      <tr>
          <td>分析粒度</td>
          <td>單筆事件（這個錯誤的 stack trace）</td>
          <td>聚合統計（過去 30 天的轉換率）</td>
      </tr>
      <tr>
          <td>決策輸出</td>
          <td>修 bug、改架構</td>
          <td>投廣告、改定價、改 UI</td>
      </tr>
      <tr>
          <td>資料保留</td>
          <td>短期（30 天，debug 用完即丟）</td>
          <td>長期（年級，行為趨勢需要歷史資料）</td>
      </tr>
      <tr>
          <td>去識別化要求</td>
          <td>中（開發者看 raw data 可接受）</td>
          <td>高（行銷分析必須去識別）</td>
      </tr>
  </tbody>
</table>
<p><strong>自架方案能做到哪裡</strong>：</p>
<ul>
<li>Funnel / cohort 基礎分析：collector 的聚合查詢 + 簡單腳本可做</li>
<li>Attribution / 推薦系統：需要專門的資料管線，超出 collector 範圍</li>
<li>A/B test：需要 feature flag 系統 + 統計檢定，屬獨立基礎設施</li>
</ul>
<p>跨系列的延伸主題（目前放在各章節的下一步路由中，尚未獨立成教學分類）：精準行銷的資料管線設計、A/B test 的統計檢定方法、推薦系統架構、隱私法規工程落地（GDPR / CCPA / 個資法）。</p>
<h3 id="跨模組橋接監控資料的雙重用途">跨模組橋接：監控資料的雙重用途</h3>
<p>SDK 送出的同一份 event data 同時服務行為分析（funnel / cohort / attribution）和 server-side 訊號治理（cardinality / cost / signal governance）。兩條消費路徑的保留期、粒度、PII 處理和取樣策略互相衝突，解法是在 transport 層分流而非在 SDK 層複製。</p>
<p>完整的資料格式交叉、治理衝突與分流架構見 <a href="/blog/monitoring/telemetry-data-dual-use/" data-link-title="監控資料的雙重用途：行為分析與訊號治理" data-link-desc="同一份 event data 如何同時服務行為分析（funnel / cohort / attribution）和訊號治理（cardinality / cost / signal governance）— 格式交叉、治理衝突與分流架構">監控資料的雙重用途</a>。Client-side event 到 server-side trace 的完整串接見 <a href="/blog/backend/04-observability/client-server-trace-integration/" data-link-title="4.24 Client-to-Server 端到端觀測串接" data-link-desc="用一個結帳場景走完 browser click → trace context → server span → 統一 waterfall 的完整實作鏈路">4.24 Client-to-Server 觀測串接</a>。</p>
<h2 id="學習路線">學習路線</h2>
<table>
  <thead>
      <tr>
          <th>路線</th>
          <th>適合讀者</th>
          <th>建議順序</th>
          <th>讀完能做什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自架監控快速上手</td>
          <td>想在自己的 app/script 加監控</td>
          <td>模組一 → 二 → 四 → 三 → data-integrity（延伸）</td>
          <td>能部署 collector + 埋點 SDK + 評估資料損失容忍度</td>
      </tr>
      <tr>
          <td>SDK 開發者</td>
          <td>想理解監控 SDK 怎麼設計</td>
          <td>模組三 → 二 → 五</td>
          <td>能設計跨平台一致的監控 SDK</td>
      </tr>
      <tr>
          <td>商業方案評估</td>
          <td>想知道什麼時候該用 Sentry / Datadog</td>
          <td>模組一 → 六</td>
          <td>能評估自架 vs 商業方案的取捨</td>
      </tr>
      <tr>
          <td>資安合規</td>
          <td>想確保蒐集的資料不會變成負債</td>
          <td>模組七 → 二（schema 設計）→ client-sdk-auth（延伸）</td>
          <td>能設計去識別化 + access control + 認證策略</td>
      </tr>
      <tr>
          <td>商業利用</td>
          <td>想把行為資料變成商業決策</td>
          <td>模組一 → 七 → 八</td>
          <td>能設計行為事件 + 基礎 funnel 分析</td>
      </tr>
      <tr>
          <td>可靠性工程</td>
          <td>想確保自架監控系統的資料完整性和安全性</td>
          <td>模組四（架構）→ data-integrity → ingestion-scaling → error-fingerprint → client-sdk-auth</td>
          <td>能設計端到端的損失控制 + SDK 認證策略</td>
      </tr>
  </tbody>
</table>
<h2 id="教學--實作互補循環">教學 × 實作互補循環</h2>
<p>本系列的教學內容和 <a href="https://github.com/tarrragon/monitor">tarrragon/monitor</a> monorepo 是互補關係，兩者各自承擔不同的知識生產責任：</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>教學（本系列）</th>
          <th>實作（monitor repo）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>職責</td>
          <td>整理理論框架、分類心智模型、設計原則</td>
          <td>驗證理論可行性、暴露理論盲區</td>
      </tr>
      <tr>
          <td>產出方向</td>
          <td>概念 → 範例 → 判斷準則</td>
          <td>程式碼 → 困難 → 新的待整理議題</td>
      </tr>
      <tr>
          <td>例子</td>
          <td>「四類事件分類」「SDK API 一致性原則」</td>
          <td>「collector 收到 10 萬筆/天時 JSONL grep 多慢？」</td>
      </tr>
  </tbody>
</table>
<p><strong>互補循環的運作方式</strong>：教學先建立理論框架（四類事件、log schema、transport 規格），實作按框架建 SDK 和 collector，實作過程撞到理論沒覆蓋的挑戰（高併發寫入、大資料查詢、儲存生命週期），挑戰回過頭成為教學的新章節。</p>
<h3 id="教學與-repo-文件分工">教學與 repo 文件分工</h3>
<p>教學和 monitor repo 的文件各自有不同的讀者和目的。教學讀者想理解「為什麼這樣設計」，repo 讀者想知道「怎麼跑起來」。</p>
<table>
  <thead>
      <tr>
          <th>內容</th>
          <th>位置</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>設計原則和判斷框架</td>
          <td>教學（本系列）</td>
          <td>跨專案可重用</td>
      </tr>
      <tr>
          <td>Quick Start（5 分鐘跑起來）</td>
          <td>monitor repo README</td>
          <td>專案綁定</td>
      </tr>
      <tr>
          <td>部署指南（systemd / config 範例）</td>
          <td>monitor repo docs/</td>
          <td>專案綁定</td>
      </tr>
      <tr>
          <td>SDK 整合範例（Flutter / Python）</td>
          <td>monitor repo 各 SDK README</td>
          <td>語言綁定</td>
      </tr>
      <tr>
          <td>Troubleshooting</td>
          <td>monitor repo docs/</td>
          <td>專案綁定</td>
      </tr>
      <tr>
          <td>Migration（SQLite → PostgreSQL）</td>
          <td>monitor repo docs/</td>
          <td>版本綁定</td>
      </tr>
  </tbody>
</table>
<p>教學讀者想要直接跑起來的步驟，見 <a href="https://github.com/tarrragon/monitor">monitor repo</a> 的 README Quick Start 段。</p>
<h3 id="mvp-驗收標準">MVP 驗收標準</h3>
<p>Monitor 的 MVP 完成定義是「一筆事件從 SDK 到 dashboard 可見」的端到端路徑跑通。</p>
<p><strong>Collector 核心（必須）</strong>：</p>
<ol>
<li><code>POST /v1/events</code> 接收 JSON 事件、schema 驗證、寫入 SQLite</li>
<li><code>GET /v1/query</code> 按 type / name / time range 查詢事件</li>
<li><code>GET /health</code> 回傳 collector 狀態</li>
<li>分層保留的 Downsample + Purge 定期執行</li>
<li>至少一個 rule（error count &gt; N → 寫檔案）</li>
</ol>
<p><strong>SDK（至少一個語言）</strong>：</p>
<ol start="6">
<li>init / event / error / flush / close 五個 API 可用</li>
<li>攢批送出（buffer + flush interval）</li>
<li>Collector 不可達時 buffer 不丟事件（記憶體 FIFO）</li>
</ol>
<p><strong>Dashboard（至少一個視圖）</strong>：</p>
<ol start="9">
<li>Error 列表（最近 N 筆 error、按 name 分群）</li>
<li>事件時間軸（按時間排序的事件流）</li>
</ol>
<p><strong>驗收方式</strong>：啟動 collector → SDK init → SDK 送 3 筆事件（1 event + 1 error + 1 lifecycle）→ dashboard 看到這 3 筆 → query API 查到這 3 筆。</p>
<p>不在 MVP 範圍：PostgreSQL backend、水平擴展、funnel / cohort 分析、A/B test、TUI dashboard、container image 發佈。</p>
<h3 id="挑戰在-collector-端不在-sdk-端">挑戰在 collector 端，不在 SDK 端</h3>
<p>SDK 埋點是已解決問題 — <code>window.onerror</code> 攔截錯誤、<code>http.post</code> 送出事件、攢批 flush，前端技術成熟且各商業方案已驗證過。SDK 的設計決策（自動攔截 vs 手動上報、flush interval、buffer 上限）有最佳實踐可循。</p>
<p>真正的挑戰在 collector 端，而且挑戰的規模隨使用者數量和時間跨度急劇增長：</p>
<table>
  <thead>
      <tr>
          <th>挑戰</th>
          <th>觸發條件</th>
          <th>教學需回補的議題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>高併發寫入</td>
          <td>多個 SDK 同時 flush → collector 瞬間收到大量 HTTP request</td>
          <td>寫入 buffer、WAL、背壓、rate limit</td>
      </tr>
      <tr>
          <td>大資料查詢</td>
          <td>累積 30 天 × 每天 10 萬筆 = 300 萬筆 → <code>grep</code> 吃光記憶體</td>
          <td>索引策略（時間分區 + 事件名稱索引）、查詢 API 設計</td>
      </tr>
      <tr>
          <td>儲存生命週期</td>
          <td>JSONL 無限增長 → 磁碟滿</td>
          <td>保留策略（TTL）、壓縮（gzip）、歸檔（冷儲存）、清除（定期 purge）</td>
      </tr>
      <tr>
          <td>聚合查詢</td>
          <td>「過去 7 天 hook.failure 的趨勢」→ 掃描 700 萬筆做 count</td>
          <td>預聚合（每小時統計寫入摘要表）、物化視圖</td>
      </tr>
      <tr>
          <td>錯誤回報查詢</td>
          <td>「最近 10 個 uncaught exception 的 stack trace」→ 全文搜尋</td>
          <td>錯誤去重（fingerprint）、stack trace 索引</td>
      </tr>
      <tr>
          <td>讀寫競爭</td>
          <td>Dashboard 聚合查詢跟 ingestion INSERT 搶 I/O 跟連線池</td>
          <td>預聚合 summary 表、read replica、讀寫分離</td>
      </tr>
  </tbody>
</table>
<p>這些挑戰的共同特徵是：<strong>在自用場景（1 人、1 台機器、每天幾百筆）完全不存在，在小規模場景（100 人、每天 10 萬筆）開始浮現，在中規模場景（1000+ 人、每天百萬筆）成為核心問題。</strong> 自架方案從「grep 就夠」演進到「需要時間序列資料庫」的過程，正好是理解商業方案為什麼那樣設計的最佳路徑。</p>
<h3 id="sqlite-實機驗證優先">SQLite 實機驗證優先</h3>
<p>Monitor repo 進入實作後，第一個驗證目標是 SQLite backend 的實機效能基準。教學的 <a href="/blog/monitoring/04-collector/sqlite-performance-baseline/" data-link-title="SQLite Backend 效能基準" data-link-desc="寫入吞吐 / 查詢延遲 / 資源消耗的量化預期 — 不同硬體環境下 SQLite 能撐多少、邊界在哪、怎麼實測">SQLite Backend 效能基準</a> 提供了基於技術特性和業界數據推導的預期範圍，但這些數字必須在目標硬體上用實測確認。</p>
<p>SQLite 版本和 PostgreSQL 版本的根本差異是 <strong>SQLite 無法擴充硬體</strong> — 它是嵌入式資料庫，和 collector 跑在同一台機器上。PostgreSQL 可以透過更大的主機、read replica、connection pool 擴展，但 SQLite 的天花板就是那台機器的 CPU + 磁碟 I/O + 記憶體。這意味著 SQLite 版本的效能邊界是硬限制，撞到就只能切換 backend，沒有「加機器」這個選項。</p>
<p>實機驗證的優先順序：</p>
<ol>
<li><strong>寫入吞吐和 database is locked 的實際閾值</strong>：教學推導的「Mac SSD 約 5,000 inserts/sec」需要在目標環境（可能是 Linux VPS 或 Raspberry Pi）實測。<code>database is locked</code> 出現的條件比理論預測更依賴硬體 — SD card 的隨機寫入延遲可能讓 WAL checkpoint 卡住數秒。</li>
<li><strong>Dashboard 查詢在真實資料量下的延遲</strong>：教學推導的「10 萬筆有索引 &lt; 100ms」需要用真實事件資料（不是生成的 dummy data）驗證 — 真實事件的 JSON 大小和欄位分佈影響索引效率。</li>
<li><strong>降採樣 job 和 purge 的執行時間</strong>：這兩個定期 job 在執行期間持有 write lock。如果 job 跑太久（數秒以上），ingestion 會 block — 需要確認在目標資料量下 job 的執行時間。</li>
<li><strong>長時間運行的穩定性</strong>：SQLite 的 WAL 檔案會持續增長直到 checkpoint。Collector 連續運行數天後的 WAL 大小、checkpoint 行為、記憶體是否有 leak — 這些只有長時間運行才會浮現。</li>
</ol>
<p>實測結果寫進 monitor repo 的 <code>docs/benchmarks/sqlite-baseline.md</code>，和教學的預期範圍對照。偏差超過 2 倍的項目回補教學章節，修正預期範圍或補充環境特定的注意事項。</p>
<h3 id="實作驅動的教學章節回補">實作驅動的教學章節回補</h3>
<p>當實作撞牆時，回補流程：</p>
<ol>
<li><strong>記錄撞牆場景</strong>：在 monitor repo 的 <code>docs/challenges/</code> 記錄具體問題（輸入規模、觀察到的症狀、嘗試的方案）</li>
<li><strong>分析根因</strong>：問題屬於哪個領域（資料庫設計 / 併發控制 / 儲存策略 / 查詢最佳化）</li>
<li><strong>回補教學章節</strong>：在 monitoring 教學系列或 <a href="/blog/backend/" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend</a> 對應模組新增章節</li>
<li><strong>交叉引用</strong>：collector 高併發問題 → <a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend 01 資料庫</a> 或 <a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">Backend 09 效能容量</a></li>
</ol>
<p>實作撞的牆越多，教學系列就越完整。商業方案（Sentry、Datadog）已經解決過這些問題 — 他們的架構選擇（ClickHouse 做事件儲存、Kafka 做寫入 buffer、Snuba 做聚合查詢）就是這些挑戰的解法。自架過一次，看商業方案的架構文件時每個決策都能理解為什麼。</p>
<h2 id="教學寫作方向">教學寫作方向</h2>
<ol>
<li><strong>自架先於商業</strong> — 先教 grep + JSONL 怎麼查問題，再說 Sentry 的 dashboard 多好用。理解底層才能判斷商業方案值不值得</li>
<li><strong>四類事件是統一語言</strong> — 所有討論都回到 event/error/metric/lifecycle 四類。商業方案差異也用這四類拆解</li>
<li><strong>實作驅動教學</strong> — monitor repo 的實作困難是教學章節的來源。撞牆 → 記錄 → 分析 → 回補章節。教學不只是寫在實作前的理論，也是寫在實作撞牆後的提煉</li>
<li><strong>規模演進是理解工具的路徑</strong> — 從 grep 到 SQLite 到時間序列 DB 的演進過程，正好是理解 Sentry / Datadog 架構選擇的最佳路徑</li>
</ol>
<hr>
<p><em>文件版本：v0.2.0</em>
<em>最後更新：2026-06-19</em>
<em>系列狀態：分類索引建立中</em></p>
]]></content:encoded></item><item><title>監控資料的雙重用途：行為分析與訊號治理</title><link>https://tarrragon.github.io/blog/monitoring/telemetry-data-dual-use/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/telemetry-data-dual-use/</guid><description>&lt;p>SDK 埋的每一筆 event 有兩個下游消費者：產品團隊用它做行為分析（轉換率、留存、歸因），工程團隊用它做訊號治理（cardinality 控制、成本歸因、事故判讀）。兩邊各自有教學章節（&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 工具到商業資產的翻轉">Monitoring 08 Business Analytics&lt;/a> 和 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend 04 可觀測性&lt;/a>），但讀者常不知道這是同一份資料的兩種消費方式。本文是橋。&lt;/p>
&lt;h2 id="同一份資料兩種消費路徑">同一份資料、兩種消費路徑&lt;/h2>





&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">SDK 埋點（event / error / metric / lifecycle）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> ├── 行為分析路徑 → Monitoring 08
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> │ 消費者：PM / 行銷 / 產品
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> │ 方法：funnel / cohort / attribution / A-B test
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> │ 決策：改 UI、調定價、投廣告
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> └── 訊號治理路徑 → Backend 04
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> 消費者：SRE / platform team / on-call
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> 方法：cardinality budget / cost attribution / signal governance
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> 決策：降 cardinality、調 sampling、改 alert、產出 evidence&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這不是兩套埋點。同一個 &lt;code>button.click&lt;/code> event，產品團隊看的是「哪個步驟流失最多使用者」，工程團隊看的是「這個 event 的 cardinality 是否在預算內、ingestion cost 是否合理」。event 相同，切入角度不同。&lt;/p>
&lt;h2 id="資料格式的交叉點">資料格式的交叉點&lt;/h2>
&lt;p>Monitoring SDK 送出的事件格式（&lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/" data-link-title="模組二：Log Schema 設計" data-link-desc="跨平台統一事件格式、欄位設計、版本演進策略">02 Log Schema&lt;/a>）和 Backend 04 的 log schema / OTel event format 有共通欄位：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>欄位&lt;/th>
 &lt;th>Monitoring SDK 格式&lt;/th>
 &lt;th>Backend 04 / OTel 格式&lt;/th>
 &lt;th>交叉用途&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>timestamp&lt;/td>
 &lt;td>&lt;code>timestamp&lt;/code>（ISO 8601）&lt;/td>
 &lt;td>&lt;code>TimeUnixNano&lt;/code>&lt;/td>
 &lt;td>兩邊都需要精確時間做時序查詢&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>event type&lt;/td>
 &lt;td>&lt;code>type&lt;/code>（event/error/metric/lifecycle）&lt;/td>
 &lt;td>&lt;code>SeverityText&lt;/code> / &lt;code>SpanKind&lt;/code>&lt;/td>
 &lt;td>行為分析按 type 做 funnel；訊號治理按 type 做 cardinality budget&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>source&lt;/td>
 &lt;td>&lt;code>source.sdk&lt;/code> / &lt;code>source.platform&lt;/code> / &lt;code>source.app&lt;/code>&lt;/td>
 &lt;td>&lt;code>Resource&lt;/code> attributes&lt;/td>
 &lt;td>行為分析按 platform 切分；訊號治理按 service 做 cost attribution&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>trace context&lt;/td>
 &lt;td>手動注入（若有）&lt;/td>
 &lt;td>&lt;code>TraceId&lt;/code> / &lt;code>SpanId&lt;/code>&lt;/td>
 &lt;td>client-to-server 端到端追蹤的串接欄位&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>payload&lt;/td>
 &lt;td>&lt;code>data&lt;/code>（自由 JSON）&lt;/td>
 &lt;td>&lt;code>Attributes&lt;/code> / &lt;code>Body&lt;/code>&lt;/td>
 &lt;td>行為分析讀 business fields；訊號治理讀 operational fields&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>格式一致性的價值是&lt;strong>一份 event 同時餵 BigQuery（行為分析）和 Grafana Loki（訊號查詢）不需要格式轉換&lt;/strong>。如果兩邊各自定義 schema，同一個 event 要寫兩次 adapter，schema drift 的風險倍增。&lt;/p></description><content:encoded><![CDATA[<p>SDK 埋的每一筆 event 有兩個下游消費者：產品團隊用它做行為分析（轉換率、留存、歸因），工程團隊用它做訊號治理（cardinality 控制、成本歸因、事故判讀）。兩邊各自有教學章節（<a href="/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">Monitoring 08 Business Analytics</a> 和 <a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend 04 可觀測性</a>），但讀者常不知道這是同一份資料的兩種消費方式。本文是橋。</p>
<h2 id="同一份資料兩種消費路徑">同一份資料、兩種消費路徑</h2>





<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">SDK 埋點（event / error / metric / lifecycle）
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  │
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  ├── 行為分析路徑 → Monitoring 08
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  │     消費者：PM / 行銷 / 產品
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  │     方法：funnel / cohort / attribution / A-B test
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  │     決策：改 UI、調定價、投廣告
</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">  └── 訊號治理路徑 → Backend 04
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        消費者：SRE / platform team / on-call
</span></span><span class="line"><span class="ln">10</span><span class="cl">        方法：cardinality budget / cost attribution / signal governance
</span></span><span class="line"><span class="ln">11</span><span class="cl">        決策：降 cardinality、調 sampling、改 alert、產出 evidence</span></span></code></pre></div><p>這不是兩套埋點。同一個 <code>button.click</code> event，產品團隊看的是「哪個步驟流失最多使用者」，工程團隊看的是「這個 event 的 cardinality 是否在預算內、ingestion cost 是否合理」。event 相同，切入角度不同。</p>
<h2 id="資料格式的交叉點">資料格式的交叉點</h2>
<p>Monitoring SDK 送出的事件格式（<a href="/blog/monitoring/02-log-schema/" data-link-title="模組二：Log Schema 設計" data-link-desc="跨平台統一事件格式、欄位設計、版本演進策略">02 Log Schema</a>）和 Backend 04 的 log schema / OTel event format 有共通欄位：</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>Monitoring SDK 格式</th>
          <th>Backend 04 / OTel 格式</th>
          <th>交叉用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>timestamp</td>
          <td><code>timestamp</code>（ISO 8601）</td>
          <td><code>TimeUnixNano</code></td>
          <td>兩邊都需要精確時間做時序查詢</td>
      </tr>
      <tr>
          <td>event type</td>
          <td><code>type</code>（event/error/metric/lifecycle）</td>
          <td><code>SeverityText</code> / <code>SpanKind</code></td>
          <td>行為分析按 type 做 funnel；訊號治理按 type 做 cardinality budget</td>
      </tr>
      <tr>
          <td>source</td>
          <td><code>source.sdk</code> / <code>source.platform</code> / <code>source.app</code></td>
          <td><code>Resource</code> attributes</td>
          <td>行為分析按 platform 切分；訊號治理按 service 做 cost attribution</td>
      </tr>
      <tr>
          <td>trace context</td>
          <td>手動注入（若有）</td>
          <td><code>TraceId</code> / <code>SpanId</code></td>
          <td>client-to-server 端到端追蹤的串接欄位</td>
      </tr>
      <tr>
          <td>payload</td>
          <td><code>data</code>（自由 JSON）</td>
          <td><code>Attributes</code> / <code>Body</code></td>
          <td>行為分析讀 business fields；訊號治理讀 operational fields</td>
      </tr>
  </tbody>
</table>
<p>格式一致性的價值是<strong>一份 event 同時餵 BigQuery（行為分析）和 Grafana Loki（訊號查詢）不需要格式轉換</strong>。如果兩邊各自定義 schema，同一個 event 要寫兩次 adapter，schema drift 的風險倍增。</p>
<h2 id="資料治理的衝突">資料治理的衝突</h2>
<p>同一份資料被兩邊消費時，治理需求會衝突：</p>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>行為分析需要</th>
          <th>訊號治理需要</th>
          <th>衝突點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>保留期</td>
          <td>長期保留（年級，趨勢與 cohort 需要歷史資料）</td>
          <td>短期保留（30-90 天，debug 用完即丟）</td>
          <td>成本 vs 分析完整度</td>
      </tr>
      <tr>
          <td>粒度</td>
          <td>高粒度（per-user、per-session、per-action）</td>
          <td>低粒度（聚合到 service / endpoint 維度）</td>
          <td>cardinality 爆炸 vs 分析精度</td>
      </tr>
      <tr>
          <td>PII 處理</td>
          <td>去識別但需保留 user segment（國家、裝置、方案）</td>
          <td>完全匿名或 redacted</td>
          <td>分析需求 vs 合規要求</td>
      </tr>
      <tr>
          <td>取樣</td>
          <td>低取樣或全量（行為趨勢需要完整分布）</td>
          <td>可以高取樣（error 全收，正常 request 取樣即可）</td>
          <td>成本 vs 覆蓋度</td>
      </tr>
      <tr>
          <td>查詢延遲</td>
          <td>可接受分鐘級（batch analytics）</td>
          <td>需要秒級（incident debug 不能等）</td>
          <td>儲存分層與查詢 backend 選擇</td>
      </tr>
  </tbody>
</table>
<p>這些衝突無法靠「選一邊」解決。行為分析少了歷史資料就看不到趨勢；訊號治理存太多高粒度資料就 cardinality 爆炸。解法是分流。</p>
<h2 id="解法在-transport-層分流">解法：在 transport 層分流</h2>
<p>把 SDK 送出的 event 在 collector 或 pipeline 層分流到不同 backend，各自按需求治理：</p>
<h3 id="hot-path即時訊號">Hot path：即時訊號</h3>
<p>error 和 metric 類事件即時進入 <a href="/blog/backend/04-observability/telemetry-pipeline/" data-link-title="4.11 Telemetry Pipeline 架構" data-link-desc="把 log / metric / trace 的 agent → collector → ingest → storage → query 分層治理">04 telemetry pipeline</a>（Loki / Prometheus / Tempo），短期 retention（30-90 天），服務 on-call debug 和 incident triage。這條路徑要求秒級延遲、低 cardinality（聚合維度）。</p>
<h3 id="warm-path行為分析">Warm path：行為分析</h3>
<p>全部四類事件進入 data warehouse（BigQuery / ClickHouse / Snowflake），長期 retention（年級），服務 funnel、cohort、attribution 和 A/B test。這條路徑接受分鐘級延遲、高粒度（per-user / per-session）。</p>
<h3 id="cold-path合規留存">Cold path：合規留存</h3>
<p>audit-level event 進入 archive storage（Cloud Storage / S3 / Glacier），法規要求的年級保留（GDPR 刪除請求、HIPAA 6 年、金融業更長）。這條路徑寫入後幾乎不查詢，查詢時接受小時級延遲。</p>
<h3 id="分流的關鍵設計">分流的關鍵設計</h3>
<p>分流在 transport 層做，不在 SDK 層做。SDK 統一送出全部 event 到同一個 endpoint，pipeline 按 event type / source / tag 路由到不同 backend。</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">SDK → Collector / OTel Collector / Cloud Logging
</span></span><span class="line"><span class="ln">2</span><span class="cl">         │
</span></span><span class="line"><span class="ln">3</span><span class="cl">         ├─ [type=error OR type=metric] → Hot path (Loki / Prometheus)
</span></span><span class="line"><span class="ln">4</span><span class="cl">         ├─ [all events]                → Warm path (BigQuery)
</span></span><span class="line"><span class="ln">5</span><span class="cl">         └─ [audit=true]               → Cold path (Cloud Storage)</span></span></code></pre></div><p>SDK 不需要知道下游有幾個消費者。新增一個消費者（例如新的分析平台）只要在 pipeline 加一條路由，不用改 SDK。</p>
<h2 id="實作考量">實作考量</h2>
<p>分流的實作方式取決於 pipeline 架構：</p>
<table>
  <thead>
      <tr>
          <th>架構</th>
          <th>分流機制</th>
          <th>適用場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自架 collector（<a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">Monitoring 04</a>）</td>
          <td>Rule engine 按 event type 寫不同 output file / HTTP endpoint</td>
          <td>小規模、自用場景</td>
      </tr>
      <tr>
          <td>OTel Collector</td>
          <td>Processor + 多個 Exporter 組成 pipeline fan-out</td>
          <td>中規模、已採用 OTel</td>
      </tr>
      <tr>
          <td>Cloud Logging（GCP）</td>
          <td>Subscription filter + Sink（BigQuery / Cloud Storage / Pub/Sub）</td>
          <td>GCP 生態</td>
      </tr>
      <tr>
          <td>Kinesis / Firehose（AWS）</td>
          <td>Firehose delivery stream + Lambda transform</td>
          <td>AWS 生態</td>
      </tr>
  </tbody>
</table>
<p>不論哪種架構，分流後的每條 path 要各自設定 retention、sampling、PII handling 和 cost budget。Hot path 的 <a href="/blog/backend/04-observability/cardinality-cost-governance/" data-link-title="4.7 Cardinality 治理與成本邊界" data-link-desc="把 metric / log / trace 的 cardinality 與成本作為平台一級治理議題">cardinality 治理</a> 規則不該影響 warm path 的分析粒度；warm path 的長期保留成本不該擠壓 hot path 的 freshness。</p>
<h2 id="常見誤區">常見誤區</h2>
<h3 id="用兩套-sdk-替代分流">用兩套 SDK 替代分流</h3>
<p>在 client 端同時整合行為分析 SDK（Mixpanel）和 error tracking SDK（Sentry），看似分工清楚，實際是兩套 schema、兩份 ingestion cost、兩組 PII 風險面、兩套 consent 管理。同一個 user action 在兩個平台各記一次，但欄位名、timestamp 精度、user identifier 可能不同，跨平台 correlation 困難。</p>
<p>統一 SDK + pipeline 分流的成本通常低於雙 SDK 的整合與治理成本。</p>
<h3 id="hot-path-存全量高粒度">Hot path 存全量高粒度</h3>
<p>把 per-user / per-session 的完整事件直接灌進 Prometheus 或 Loki，會導致 cardinality 爆炸（<a href="/blog/backend/04-observability/cardinality-cost-governance/" data-link-title="4.7 Cardinality 治理與成本邊界" data-link-desc="把 metric / log / trace 的 cardinality 與成本作為平台一級治理議題">4.7 Cardinality 治理</a>）。Hot path 的正確做法是在 pipeline 層做 aggregation 或 relabeling，只保留 service / endpoint / status 等低 cardinality 維度。高粒度資料走 warm path。</p>
<h3 id="warm-path-不做-pii-處理">Warm path 不做 PII 處理</h3>
<p>行為分析需要 user segment，但不需要 PII 原文。warm path 的 ingestion pipeline 應該在寫入 warehouse 前做 PII redaction（hash user_id、truncate IP、strip email）。<a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">Monitoring 07 去識別化</a> 的策略同時適用於 hot 和 warm path。</p>
<h2 id="讀者路由">讀者路由</h2>
<table>
  <thead>
      <tr>
          <th>如果你想</th>
          <th>先讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>理解 event 格式設計</td>
          <td><a href="/blog/monitoring/02-log-schema/" data-link-title="模組二：Log Schema 設計" data-link-desc="跨平台統一事件格式、欄位設計、版本演進策略">Monitoring 02 Log Schema</a></td>
      </tr>
      <tr>
          <td>理解行為分析方法</td>
          <td><a href="/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">Monitoring 08 Business Analytics</a></td>
      </tr>
      <tr>
          <td>理解訊號治理和成本控制</td>
          <td><a href="/blog/backend/04-observability/cardinality-cost-governance/" data-link-title="4.7 Cardinality 治理與成本邊界" data-link-desc="把 metric / log / trace 的 cardinality 與成本作為平台一級治理議題">Backend 04 Cardinality 治理</a>、<a href="/blog/backend/04-observability/cost-attribution/" data-link-title="4.15 Cost Attribution / Chargeback" data-link-desc="把 observability 成本拆到團隊、產品、環境維度">4.15 Cost Attribution</a></td>
      </tr>
      <tr>
          <td>理解 pipeline 分流架構</td>
          <td><a href="/blog/backend/04-observability/telemetry-pipeline/" data-link-title="4.11 Telemetry Pipeline 架構" data-link-desc="把 log / metric / trace 的 agent → collector → ingest → storage → query 分層治理">Backend 04 Telemetry Pipeline</a></td>
      </tr>
      <tr>
          <td>理解 PII 去識別化</td>
          <td><a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">Monitoring 07 Security Privacy</a></td>
      </tr>
      <tr>
          <td>理解 client-to-server 端到端觀測串接</td>
          <td><a href="/blog/backend/04-observability/client-server-trace-integration/" data-link-title="4.24 Client-to-Server 端到端觀測串接" data-link-desc="用一個結帳場景走完 browser click → trace context → server span → 統一 waterfall 的完整實作鏈路">Backend 04 Client-to-Server 觀測串接</a></td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>監控案例庫</title><link>https://tarrragon.github.io/blog/monitoring/cases/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/cases/</guid><description>&lt;p>本案例庫的來源與 testing / ux-design 不同：案例由 &lt;a href="https://github.com/tarrragon/monitor">tarrragon/monitor&lt;/a> 的實作過程產生，不是事前採集。&lt;/p>
&lt;p>每個案例對應 monitor repo 的 &lt;code>docs/challenges/&lt;/code> 中的一個撞牆記錄，經教學化處理後收錄於此。&lt;/p>
&lt;h2 id="預期案例實作後產生">預期案例（實作後產生）&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>預期主題&lt;/th>
 &lt;th>觸發時機&lt;/th>
 &lt;th>對應模組&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>JSONL 查詢效能天花板&lt;/td>
 &lt;td>累積 &amp;gt; 1 萬筆&lt;/td>
 &lt;td>模組四&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>高併發寫入 buffer 策略&lt;/td>
 &lt;td>多 SDK 同時 flush&lt;/td>
 &lt;td>模組四&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SDK 離線 buffer 丟失&lt;/td>
 &lt;td>網路中斷 + buffer 滿&lt;/td>
 &lt;td>模組三&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨平台 timestamp 偏移&lt;/td>
 &lt;td>JS/Dart/Python 時間精度不同&lt;/td>
 &lt;td>模組五&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>錯誤去重 fingerprint 設計&lt;/td>
 &lt;td>同一 exception 重複回報&lt;/td>
 &lt;td>模組三&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Redaction false positive&lt;/td>
 &lt;td>正常內容被誤判為 secret&lt;/td>
 &lt;td>模組七&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>聚合查詢掃描量爆炸&lt;/td>
 &lt;td>「過去 7 天趨勢」&lt;/td>
 &lt;td>模組四&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>案例庫會隨實作進展持續擴充。&lt;/p></description><content:encoded><![CDATA[<p>本案例庫的來源與 testing / ux-design 不同：案例由 <a href="https://github.com/tarrragon/monitor">tarrragon/monitor</a> 的實作過程產生，不是事前採集。</p>
<p>每個案例對應 monitor repo 的 <code>docs/challenges/</code> 中的一個撞牆記錄，經教學化處理後收錄於此。</p>
<h2 id="預期案例實作後產生">預期案例（實作後產生）</h2>
<table>
  <thead>
      <tr>
          <th>預期主題</th>
          <th>觸發時機</th>
          <th>對應模組</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>JSONL 查詢效能天花板</td>
          <td>累積 &gt; 1 萬筆</td>
          <td>模組四</td>
      </tr>
      <tr>
          <td>高併發寫入 buffer 策略</td>
          <td>多 SDK 同時 flush</td>
          <td>模組四</td>
      </tr>
      <tr>
          <td>SDK 離線 buffer 丟失</td>
          <td>網路中斷 + buffer 滿</td>
          <td>模組三</td>
      </tr>
      <tr>
          <td>跨平台 timestamp 偏移</td>
          <td>JS/Dart/Python 時間精度不同</td>
          <td>模組五</td>
      </tr>
      <tr>
          <td>錯誤去重 fingerprint 設計</td>
          <td>同一 exception 重複回報</td>
          <td>模組三</td>
      </tr>
      <tr>
          <td>Redaction false positive</td>
          <td>正常內容被誤判為 secret</td>
          <td>模組七</td>
      </tr>
      <tr>
          <td>聚合查詢掃描量爆炸</td>
          <td>「過去 7 天趨勢」</td>
          <td>模組四</td>
      </tr>
  </tbody>
</table>
<p>案例庫會隨實作進展持續擴充。</p>
]]></content:encoded></item><item><title>LLM Service 偵測訊號覆蓋</title><link>https://tarrragon.github.io/blog/backend/07-security-data-protection/llm-as-service-detection-coverage/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/07-security-data-protection/llm-as-service-detection-coverage/</guid><description>&lt;p>本章的責任是把 LLM 服務的異常行為訊號、納入 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/detection-coverage-and-signal-governance/" data-link-title="7.13 偵測覆蓋率與訊號治理" data-link-desc="定義偵測覆蓋、訊號品質與誤報成本的治理問題">7.13 偵測覆蓋與訊號治理&lt;/a> 的既有偵測框架。LLM 服務的偵測訊號跟一般 service 的差異在「需要看 prompt / response / tool call 三個語意層」、不只是 traffic 跟 error rate；LLM-specific 訊號的關鍵範例是 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/refusal-rate/" data-link-title="Refusal Rate" data-link-desc="LLM 拒絕回答 prompt 的比例、是 production LLM 服務偵測對齊強度跟異常行為的常用訊號">refusal rate&lt;/a>、通用 alerting 詞彙見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/alert/" data-link-title="Alert" data-link-desc="說明 alert 如何把需要處理的服務症狀轉成可行動通知">alert&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/alert-fatigue/" data-link-title="Alert Fatigue" data-link-desc="說明過多低品質告警如何降低 on-call 反應品質">alert-fatigue&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/symptom-based-alert/" data-link-title="Symptom-Based Alert" data-link-desc="說明告警應優先偵測使用者可感知症狀">symptom-based-alert&lt;/a> 卡。本章聚焦這層特殊性、通用偵測流程沿用 7.13。&lt;/p>
&lt;h2 id="本章寫作邊界">本章寫作邊界&lt;/h2>
&lt;p>本章聚焦 production LLM 服務的偵測訊號設計：tool call 異常、prompt injection 觸發徵兆、abuse 模式、cost / token 異常、模型行為偏移。通用偵測平台選型與 SIEM / SOAR 整合屬 &lt;code>04-observability&lt;/code> 跟 7.13。&lt;/p>
&lt;h2 id="本章-threat-scope">本章 threat scope&lt;/h2>
&lt;p>&lt;strong>In-scope&lt;/strong>：LLM 服務的特殊偵測訊號（prompt / response / tool call 語意層）、agent 行為異常、abuse / 濫用模式、cost 異常、模型 drift。&lt;/p>
&lt;p>&lt;strong>Out-of-scope&lt;/strong>（路由到他章）：&lt;/p>
&lt;ul>
&lt;li>通用偵測覆蓋與訊號治理 → &lt;a href="../detection-coverage-and-signal-governance/">7.13 detection-coverage-and-signal-governance&lt;/a>&lt;/li>
&lt;li>偵測平台 → &lt;code>04-observability&lt;/code>&lt;/li>
&lt;li>IR 工作流 → &lt;a href="../incident-case-to-control-workflow/">7.10 incident-case-to-control-workflow&lt;/a>&lt;/li>
&lt;li>agent prompt injection 後果 → &lt;a href="../llm-prompt-injection-in-agent/">llm-prompt-injection-in-agent&lt;/a>&lt;/li>
&lt;li>log / PII 治理 → &lt;a href="../llm-log-and-pii-governance/">llm-log-and-pii-governance&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="從本章到實作">從本章到實作&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Mechanism&lt;/strong>：問題節點表 → knowledge-card。&lt;/li>
&lt;li>&lt;strong>Delivery&lt;/strong>：交接路由 → &lt;code>04-observability&lt;/code> 偵測平台、&lt;code>08-incident-response&lt;/code> IR 流程。&lt;/li>
&lt;/ul>
&lt;h2 id="llm-服務的偵測語意層">LLM 服務的偵測語意層&lt;/h2>
&lt;p>一般 service 的偵測訊號集中在 traffic / error / latency / auth event；LLM 服務增加了三個語意層：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>prompt 語意層&lt;/strong>：使用者輸入的內容模式、prompt 長度分布、特殊 token / pattern 出現頻率。&lt;/li>
&lt;li>&lt;strong>response 語意層&lt;/strong>：模型輸出的內容類型、refusal rate、輸出長度分布、tool call 出現模式。&lt;/li>
&lt;li>&lt;strong>tool call 序列層&lt;/strong>：agent 場景下、tool call 順序、頻率、跨 tool 依賴模式。&lt;/li>
&lt;/ol>
&lt;p>這三層的訊號通常無法用傳統 monitoring stack 直接抓、需要 LLM-specific 的 telemetry pipeline。&lt;/p>
&lt;h2 id="分析模型">分析模型&lt;/h2>
&lt;p>LLM 服務偵測依四個層次設計訊號：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>traffic 層&lt;/strong>：跟一般 service 一致、QPS / latency / error rate / auth event。&lt;/li>
&lt;li>&lt;strong>content 層&lt;/strong>：prompt 跟 response 的語意特徵（長度、token 類型、敏感詞）。&lt;/li>
&lt;li>&lt;strong>behavior 層&lt;/strong>：tool call 序列、agent loop 步數、cross-service call pattern。&lt;/li>
&lt;li>&lt;strong>cost 層&lt;/strong>：token / call 累積、cost 異常（單一 tenant 突然暴增、cost-per-result 飆高）。&lt;/li>
&lt;/ol>
&lt;h2 id="判讀流程">判讀流程&lt;/h2>
&lt;p>判讀流程的責任是把「能偵測一般服務異常的偵測平台」擴成「能偵測 LLM 特殊異常的偵測平台」。&lt;/p></description><content:encoded><![CDATA[<p>本章的責任是把 LLM 服務的異常行為訊號、納入 <a href="/blog/backend/07-security-data-protection/detection-coverage-and-signal-governance/" data-link-title="7.13 偵測覆蓋率與訊號治理" data-link-desc="定義偵測覆蓋、訊號品質與誤報成本的治理問題">7.13 偵測覆蓋與訊號治理</a> 的既有偵測框架。LLM 服務的偵測訊號跟一般 service 的差異在「需要看 prompt / response / tool call 三個語意層」、不只是 traffic 跟 error rate；LLM-specific 訊號的關鍵範例是 <a href="/blog/llm/knowledge-cards/refusal-rate/" data-link-title="Refusal Rate" data-link-desc="LLM 拒絕回答 prompt 的比例、是 production LLM 服務偵測對齊強度跟異常行為的常用訊號">refusal rate</a>、通用 alerting 詞彙見 <a href="/blog/backend/knowledge-cards/alert/" data-link-title="Alert" data-link-desc="說明 alert 如何把需要處理的服務症狀轉成可行動通知">alert</a>、<a href="/blog/backend/knowledge-cards/alert-fatigue/" data-link-title="Alert Fatigue" data-link-desc="說明過多低品質告警如何降低 on-call 反應品質">alert-fatigue</a>、<a href="/blog/backend/knowledge-cards/symptom-based-alert/" data-link-title="Symptom-Based Alert" data-link-desc="說明告警應優先偵測使用者可感知症狀">symptom-based-alert</a> 卡。本章聚焦這層特殊性、通用偵測流程沿用 7.13。</p>
<h2 id="本章寫作邊界">本章寫作邊界</h2>
<p>本章聚焦 production LLM 服務的偵測訊號設計：tool call 異常、prompt injection 觸發徵兆、abuse 模式、cost / token 異常、模型行為偏移。通用偵測平台選型與 SIEM / SOAR 整合屬 <code>04-observability</code> 跟 7.13。</p>
<h2 id="本章-threat-scope">本章 threat scope</h2>
<p><strong>In-scope</strong>：LLM 服務的特殊偵測訊號（prompt / response / tool call 語意層）、agent 行為異常、abuse / 濫用模式、cost 異常、模型 drift。</p>
<p><strong>Out-of-scope</strong>（路由到他章）：</p>
<ul>
<li>通用偵測覆蓋與訊號治理 → <a href="../detection-coverage-and-signal-governance/">7.13 detection-coverage-and-signal-governance</a></li>
<li>偵測平台 → <code>04-observability</code></li>
<li>IR 工作流 → <a href="../incident-case-to-control-workflow/">7.10 incident-case-to-control-workflow</a></li>
<li>agent prompt injection 後果 → <a href="../llm-prompt-injection-in-agent/">llm-prompt-injection-in-agent</a></li>
<li>log / PII 治理 → <a href="../llm-log-and-pii-governance/">llm-log-and-pii-governance</a></li>
</ul>
<h2 id="從本章到實作">從本章到實作</h2>
<ul>
<li><strong>Mechanism</strong>：問題節點表 → knowledge-card。</li>
<li><strong>Delivery</strong>：交接路由 → <code>04-observability</code> 偵測平台、<code>08-incident-response</code> IR 流程。</li>
</ul>
<h2 id="llm-服務的偵測語意層">LLM 服務的偵測語意層</h2>
<p>一般 service 的偵測訊號集中在 traffic / error / latency / auth event；LLM 服務增加了三個語意層：</p>
<ol>
<li><strong>prompt 語意層</strong>：使用者輸入的內容模式、prompt 長度分布、特殊 token / pattern 出現頻率。</li>
<li><strong>response 語意層</strong>：模型輸出的內容類型、refusal rate、輸出長度分布、tool call 出現模式。</li>
<li><strong>tool call 序列層</strong>：agent 場景下、tool call 順序、頻率、跨 tool 依賴模式。</li>
</ol>
<p>這三層的訊號通常無法用傳統 monitoring stack 直接抓、需要 LLM-specific 的 telemetry pipeline。</p>
<h2 id="分析模型">分析模型</h2>
<p>LLM 服務偵測依四個層次設計訊號：</p>
<ol>
<li><strong>traffic 層</strong>：跟一般 service 一致、QPS / latency / error rate / auth event。</li>
<li><strong>content 層</strong>：prompt 跟 response 的語意特徵（長度、token 類型、敏感詞）。</li>
<li><strong>behavior 層</strong>：tool call 序列、agent loop 步數、cross-service call pattern。</li>
<li><strong>cost 層</strong>：token / call 累積、cost 異常（單一 tenant 突然暴增、cost-per-result 飆高）。</li>
</ol>
<h2 id="判讀流程">判讀流程</h2>
<p>判讀流程的責任是把「能偵測一般服務異常的偵測平台」擴成「能偵測 LLM 特殊異常的偵測平台」。</p>
<ol>
<li>先盤點現有偵測平台覆蓋哪些訊號類別、哪些是 LLM-specific 缺漏。</li>
<li>再設計 LLM-specific 訊號的採集路徑（log → metric → alert）。</li>
<li>接著定義 baseline 跟 anomaly threshold、避免假陽性過高。</li>
<li>最後交接到 IR 流程、確認 alert 能對應到具體處置動作。</li>
</ol>
<h2 id="問題節點案例觸發式">問題節點（案例觸發式）</h2>
<table>
  <thead>
      <tr>
          <th>問題節點</th>
          <th>判讀訊號</th>
          <th>風險後果</th>
          <th>前置控制面</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>tool call 序列異常</td>
          <td>同一 session 內 tool call 暴增、跨 tool 跳躍頻繁</td>
          <td>injection 觸發 agent 進入非預期 loop</td>
          <td><a href="/blog/backend/07-security-data-protection/detection-coverage-and-signal-governance/" data-link-title="7.13 偵測覆蓋率與訊號治理" data-link-desc="定義偵測覆蓋、訊號品質與誤報成本的治理問題">detection-coverage-and-signal-governance</a></td>
      </tr>
      <tr>
          <td>Refusal rate 突然下降</td>
          <td>模型開始接受原本拒絕的 prompt</td>
          <td>對齊被繞過、injection 攻擊在進行</td>
          <td><a href="/blog/backend/knowledge-cards/symptom-based-alert/" data-link-title="Symptom-Based Alert" data-link-desc="說明告警應優先偵測使用者可感知症狀">symptom-based-alert</a></td>
      </tr>
      <tr>
          <td>token usage 異常飆升</td>
          <td>單一 tenant cost 跳一個量級</td>
          <td>abuse / DoS / 自動化攻擊</td>
          <td><a href="/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">rate-limit</a></td>
      </tr>
      <tr>
          <td>prompt 含 injection 模式</td>
          <td>&ldquo;ignore previous instructions&rdquo; / 大量 system prompt 字樣</td>
          <td>已知 injection 模式試探</td>
          <td><a href="/blog/backend/knowledge-cards/symptom-based-alert/" data-link-title="Symptom-Based Alert" data-link-desc="說明告警應優先偵測使用者可感知症狀">symptom-based-alert</a></td>
      </tr>
      <tr>
          <td>response 含 PII 模式</td>
          <td>模型輸出含信用卡 / 身分證號碼 pattern</td>
          <td>訓練資料洩漏 / hallucinate PII</td>
          <td><a href="/blog/backend/07-security-data-protection/data-protection-and-masking-governance/" data-link-title="7.4 資料保護與遮罩治理" data-link-desc="以問題驅動方式整理資料分級、遮罩、匯出與備份治理">data-protection</a></td>
      </tr>
      <tr>
          <td>跨 tenant pattern 相似性</td>
          <td>不同 tenant 同時出現相似異常 prompt</td>
          <td>協同攻擊 / botnet</td>
          <td><a href="/blog/backend/knowledge-cards/symptom-based-alert/" data-link-title="Symptom-Based Alert" data-link-desc="說明告警應優先偵測使用者可感知症狀">symptom-based-alert</a></td>
      </tr>
      <tr>
          <td>模型 drift</td>
          <td>同 prompt 在不同時段 response 品質明顯變化</td>
          <td>模型版本切換問題 / vendor 端變動</td>
          <td><a href="/blog/backend/knowledge-cards/contract/" data-link-title="Boundary Contract" data-link-desc="說明跨邊界約定如何維持相容與可驗證">contract-test</a></td>
      </tr>
  </tbody>
</table>
<h2 id="常見風險邊界">常見風險邊界</h2>
<p>風險邊界的責任是界定何時 LLM 偵測覆蓋已進入高壓狀態。</p>
<ul>
<li>tool call 序列、refusal rate、token usage 任一缺乏 baseline 時、代表 content / behavior / cost 層偵測不足。</li>
<li>prompt injection 已知 pattern 沒列入 alert 時、代表已知威脅未覆蓋。</li>
<li>跨 tenant 模式分析缺失時、代表協同攻擊偵測能力不足。</li>
<li>alert 沒對應到 IR 處置動作時、代表偵測與處置斷層。</li>
</ul>
<h2 id="llm-場景的特殊判讀">LLM 場景的特殊判讀</h2>
<p>LLM 服務偵測相對一般 service 偵測的特殊性：</p>
<ol>
<li><strong>訊號是非結構化的</strong>：prompt / response 是自由文字、不是 status code 跟 endpoint name；偵測 pipeline 需要 NLP / embedding 等手段、不只是 grep / regex。</li>
<li><strong>baseline 漂移</strong>：使用者行為跟 LLM 使用模式持續演進、baseline 比一般 service 更需要 rolling window 更新。</li>
<li><strong>「正常」prompt 跟「injection」prompt 的邊界模糊</strong>：教 LLM 寫 prompt injection 教材的使用者、prompt 內容跟攻擊者的測試 prompt 形式上類似；偵測需要結合 intent 跟 context。</li>
<li><strong>cost-based detection 是 LLM 特有的 strong signal</strong>：傳統 service 的「cost」對應 infra、容易被視為運維議題；LLM service 的 token cost 直接連結到 abuse、cost 異常本身是強訊號。</li>
<li><strong>跨 tenant 相關性分析</strong>：協同攻擊跟 botnet 在 LLM 服務上、可能用相同 prompt 在不同帳號試探；跨 tenant pattern 分析比一般 service 更有用。</li>
<li><strong>模型 vendor 是 third-party 失敗點</strong>：vendor 端的模型更新、API 限流、政策變更會直接影響服務行為；需要 vendor-side 訊號（status page、release notes）納入偵測範圍。</li>
</ol>
<h2 id="訊號設計的核心原則">訊號設計的核心原則</h2>
<ol>
<li><strong>traffic 層沿用既有監控</strong>：QPS / latency / error rate / 5xx、跟一般 service 一致、用既有平台。</li>
<li><strong>content 層需建 NLP pipeline</strong>：prompt 長度分布、敏感詞 detector、injection pattern detector、response PII detector。</li>
<li><strong>behavior 層追蹤 tool call 序列</strong>：每個 session 的 tool call DAG、跟 baseline 比對。</li>
<li><strong>cost 層做 tenant-scoped baseline</strong>：每個 tenant 的 token / cost 用 rolling baseline、突破 threshold 觸發 alert。</li>
<li><strong>跨 tenant pattern 用 embedding 相似性</strong>：用 prompt embedding 做相似性分析、找協同攻擊。</li>
<li><strong>vendor-side 訊號納入</strong>：vendor status page、release notes、incident 公告應該 watch、作為 external signal source。</li>
</ol>
<h2 id="案例觸發參考">案例觸發參考</h2>
<p>LLM 服務偵測的公開案例累積中、值得追蹤的方向：</p>
<ul>
<li>大型 LLM vendor 的 abuse detection pipeline 公開介紹</li>
<li>prompt injection 攻擊在 production agent 場景的真實案例</li>
<li>token usage abuse 的 botnet 案例</li>
</ul>
<p>LLM-specific 偵測案例累積後會補入 <code>red-team/cases/llm-detection/</code>。一般偵測案例見 <a href="/blog/backend/07-security-data-protection/detection-coverage-and-signal-governance/" data-link-title="7.13 偵測覆蓋率與訊號治理" data-link-desc="定義偵測覆蓋、訊號品質與誤報成本的治理問題">7.13 detection-coverage-and-signal-governance</a>。</p>
<blockquote>
<p><strong>事實查核註</strong>：LLM 服務的偵測 baseline、attack pattern、defense 工具都在快速演進、本章列舉的訊號類型為 2026 年 5 月常見社群實踐、具體 threshold、tooling、commercial product 依時段變化、引用前以最新研究跟產品文件為準。</p></blockquote>
<h2 id="引用標準">引用標準</h2>
<table>
  <thead>
      <tr>
          <th>標準</th>
          <th>版本 / 年份</th>
          <th>適用場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>MITRE ATLAS</td>
          <td>continuous</td>
          <td>AI 系統威脅戰術 / 偵測戰術 reference</td>
      </tr>
      <tr>
          <td>OWASP LLM Top 10</td>
          <td>2025</td>
          <td>LLM application security 通用 reference</td>
      </tr>
      <tr>
          <td>NIST AI RMF</td>
          <td>1.0 (2023)</td>
          <td>AI 系統風險偵測 reference</td>
      </tr>
      <tr>
          <td>MITRE ATT&amp;CK</td>
          <td>continuous</td>
          <td>一般系統威脅戰術、部分適用 LLM 服務基礎設施</td>
      </tr>
  </tbody>
</table>
<p>引用版本與 cadence 規則見 <a href="/blog/report/security-citation-currency-and-precision/" data-link-title="Security 標準引用的時效性與精確度" data-link-desc="資安 citation 跟一般技術引用不同——best practice 時效短（MD5 / SHA-1 / bcrypt 100k / TLS 1.0 都曾是 best practice）、原文常被引用扭曲（conditional → unconditional drift）、版本不標 reader 會套用過時 spec。citation 同時涵蓋外部標準（OWASP / RFC / NIST / CIS）跟內部 citation（knowledge-cards / 跨章引用作為 control-of-record）；後者因無版本號 anchor 反而更易 silent drift / broken。每條 citation 必須附：版本 / 年份、引用句意可回溯、deprecated / superseded 標記、強度參數對應 actor 能力的 review trigger（外部）/ last-checked &#43; sync owner（內部）。">security-citation-currency-and-precision</a>。Last reviewed: 2026-05-12。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>通用偵測覆蓋：<a href="/blog/backend/07-security-data-protection/detection-coverage-and-signal-governance/" data-link-title="7.13 偵測覆蓋率與訊號治理" data-link-desc="定義偵測覆蓋、訊號品質與誤報成本的治理問題">7.13 detection-coverage-and-signal-governance</a></li>
<li>偵測平台：<code>04-observability</code></li>
<li>agent prompt injection 後果：<a href="/blog/backend/07-security-data-protection/llm-prompt-injection-in-agent/" data-link-title="LLM Agent Prompt Injection 後果治理" data-link-desc="production LLM agent 場景的 prompt injection 後果：tool spec 設計、agent loop 限制、review checkpoint、跟 incident workflow 的接合">llm-prompt-injection-in-agent</a></li>
<li>log / PII 治理：<a href="/blog/backend/07-security-data-protection/llm-log-and-pii-governance/" data-link-title="LLM Log 與 PII 治理" data-link-desc="production LLM 服務的 prompt log 累積、PII 偵測與過濾、保留期限與合規對齊">llm-log-and-pii-governance</a></li>
<li>事件案例工作流：<a href="/blog/backend/07-security-data-protection/incident-case-to-control-workflow/" data-link-title="7.16 從公開事故到工程 Workflow：案例如何回寫控制面" data-link-desc="建立公開事故如何轉成控制面失效樣式與 workflow 回寫的大綱">7.10 incident-case-to-control-workflow</a></li>
</ul>
]]></content:encoded></item><item><title>Monitoring 知識卡片</title><link>https://tarrragon.github.io/blog/monitoring/knowledge-cards/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/knowledge-cards/</guid><description>&lt;p>監控體系教學中出現的關鍵術語卡片。每張卡片說明一個語意責任，跨情境變義的概念拆成獨立卡片。&lt;/p></description><content:encoded>&lt;p>監控體系教學中出現的關鍵術語卡片。每張卡片說明一個語意責任，跨情境變義的概念拆成獨立卡片。&lt;/p>
</content:encoded></item><item><title>TUI 監控工具：btop、htop、k9s 的遠端使用與刷新率調校</title><link>https://tarrragon.github.io/blog/linux/tools/cli/tui-monitoring-tools/</link><pubDate>Mon, 15 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/tools/cli/tui-monitoring-tools/</guid><description>&lt;p>TUI 監控工具負責把系統或叢集的即時狀態畫成全螢幕互動介面：即時呈現負載變化，並用鍵盤直接排序、過濾、送訊號，取代反覆敲 &lt;code>ps&lt;/code>、&lt;code>df&lt;/code>、&lt;code>free&lt;/code> 再自行拼湊。在遠端 SSH 情境下，它的關鍵變數是刷新率與頻寬的取捨，因為全螢幕介面每次刷新都會重送整片畫面。&lt;/p>
&lt;p>本文承接 &lt;a href="https://tarrragon.github.io/blog/linux/tools/cli/cli-graphical-tools-overview/" data-link-title="終端機圖形化工具總覽：遠端操作下的 TUI、文字圖表與多工器" data-link-desc="在純文字終端機裡用 ASCII 與製圖字元做出監控儀表板、資料圖表與多視窗操作的工具總覽，並針對 SSH 伺服器、手機平板、低頻寬三種遠端情境給出選型判讀。">終端機圖形化工具總覽&lt;/a> 的 TUI 工具脈絡，聚焦系統監控這一支在遠端的實際使用與調校。git 線圖工具（&lt;code>tig&lt;/code> / &lt;code>lazygit&lt;/code> / &lt;code>gitui&lt;/code>）雖然也是 TUI，但屬版控子題，獨立成 &lt;a href="https://tarrragon.github.io/blog/linux/tools/cli/git-line-graph-tools-for-remote-cli/" data-link-title="遠端 CLI 開發的 git 線圖工具選型：tig、lazygit、gitui 與管線增強" data-link-desc="純 CLI、遠端開發情境下查看 git 分支線圖的工具地景，從 tig 唯讀瀏覽到 lazygit/gitui 操作中樞的定位差異，含選型判準與 lazygit 上手、delta side-by-side diff 設定。">遠端 CLI 開發的 git 線圖工具選型&lt;/a>。&lt;/p>
&lt;h2 id="htop進程層的標準">htop：進程層的標準&lt;/h2>
&lt;p>htop 把進程清單畫成帶 CPU 與記憶體長條的全螢幕視圖，責任是即時看進程並直接操作。它用底部的功能鍵列引導操作，不必背指令。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>按鍵&lt;/th>
 &lt;th>作用&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>F3&lt;/code>&lt;/td>
 &lt;td>搜尋進程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>F4&lt;/code>&lt;/td>
 &lt;td>過濾（只顯示符合的進程）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>F5&lt;/code>&lt;/td>
 &lt;td>樹狀檢視（看父子關係）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>F6&lt;/code>&lt;/td>
 &lt;td>選排序欄位&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>F9&lt;/code>&lt;/td>
 &lt;td>送訊號（殺進程）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>F10&lt;/code>&lt;/td>
 &lt;td>離開&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>遠端使用的關鍵是刷新延遲。htop 用 &lt;code>-d&lt;/code> 設定刷新間隔，單位是十分之一秒，所以 &lt;code>htop -d 30&lt;/code> 是每 3 秒刷新一次。慢速連線下把延遲調大換取畫面不卡、按鍵不延遲，可從 5 秒（&lt;code>htop -d 50&lt;/code>）起步，順了再往下調。這個 5 秒是經驗起點、不是測得的閾值，實際依連線 RTT 與終端尺寸調整（後面 btop 與判讀段沿用此基準）。&lt;/p>
&lt;h2 id="btop多資源儀表板">btop：多資源儀表板&lt;/h2>
&lt;p>btop 把 CPU、記憶體、網路、磁碟畫在同一畫面，並帶歷史曲線與滑鼠操作，責任是一眼總覽多個資源維度的趨勢。相較 htop 偏進程清單，btop 偏向整機儀表板。&lt;/p>
&lt;p>刷新率是 btop 在遠端最該調的設定。它的刷新間隔由 &lt;code>update_ms&lt;/code> 控制（預設 2000 毫秒），把間隔調短會讓全螢幕重畫更頻繁、在慢速連線吃掉頻寬。調整方式是按 &lt;code>Esc&lt;/code> 開 Options 選單改 &lt;code>update_ms&lt;/code>，或直接編輯設定檔 &lt;code>~/.config/btop/btop.conf&lt;/code> 的 &lt;code>update_ms&lt;/code> 值。判讀分界與 htop 相同：連線品質好可用較密的刷新換即時性，品質差就把間隔拉長，慢速連線可從 &lt;code>update_ms 5000&lt;/code>（5 秒）起步。&lt;/p>
&lt;h2 id="k9skubernetes-叢集導航">k9s：Kubernetes 叢集導航&lt;/h2>
&lt;p>k9s 把 &lt;code>kubectl&lt;/code> 的查詢與操作做成全螢幕導航介面，責任是讓叢集管理不必逐條敲 &lt;code>kubectl&lt;/code> 指令。它用冒號指令切換資源視圖，游標選中資源後用快捷鍵操作。&lt;/p>
&lt;blockquote>
&lt;p>安裝與 &lt;code>--refresh&lt;/code> 旗標已實機驗證；以下 &lt;code>:pods&lt;/code> 等叢集操作需連到 k8s cluster，依官方用法、本機未實機驗證。&lt;/p>&lt;/blockquote>
&lt;p>常見操作是輸入 &lt;code>:pods&lt;/code> 看 pod 清單、&lt;code>:svc&lt;/code> 看 service，游標停在某個 pod 上按 &lt;code>l&lt;/code> 看 log、&lt;code>d&lt;/code> 看 describe、&lt;code>s&lt;/code> 進 container shell。對遠端管理叢集的情境，它把「查狀態到進去除錯」的流程收進同一畫面，省去反覆切換指令的負擔。k9s 同樣是全螢幕 TUI、會定期輪詢叢集狀態，慢速連線下導航延遲明顯時，可在啟動時用 &lt;code>--refresh&lt;/code> 把輪詢間隔（秒）調長。&lt;/p>
&lt;h2 id="其他常用-tui-監控">其他常用 TUI 監控&lt;/h2>
&lt;p>不同資源維度有各自的專用 TUI，責任聚焦在單一面向。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工具&lt;/th>
 &lt;th>監控對象&lt;/th>
 &lt;th>用途&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>ncdu&lt;/code> / &lt;code>gdu&lt;/code>&lt;/td>
 &lt;td>磁碟空間&lt;/td>
 &lt;td>掃描目錄並用長條顯示各目錄佔多少空間&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>ctop&lt;/code>&lt;/td>
 &lt;td>容器&lt;/td>
 &lt;td>即時看各 container 的資源佔用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>dive&lt;/code>&lt;/td>
 &lt;td>映像層&lt;/td>
 &lt;td>逐層分析 Docker image 的大小組成&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這些工具的共同特性是各管一個維度：磁碟爆了用 &lt;code>ncdu&lt;/code> 找出是哪一包、容器資源異常用 &lt;code>ctop&lt;/code> 定位、要拆解 image 肥在哪用 &lt;code>dive&lt;/code>。遠端排查時依問題維度挑對應工具，比開一個大而全的儀表板更直接。&lt;/p>
&lt;p>Docker 相關的兩個工具值得多記一筆。&lt;code>dive&lt;/code> 除了 TUI，還有非互動的 &lt;code>--ci&lt;/code> 模式：&lt;code>dive &amp;lt;image&amp;gt; --ci&lt;/code> 會輸出 image 的 efficiency 與 wasted space，並依門檻判定 pass/fail，適合塞進 CI pipeline 擋住臃腫 image。&lt;code>ctop&lt;/code> 的單一容器細節視圖（游標選中按 &lt;code>Enter&lt;/code>）會把環境變數明文列出，含資料庫密碼這類敏感值，共享畫面或側錄時要留意。&lt;/p></description><content:encoded><![CDATA[<p>TUI 監控工具負責把系統或叢集的即時狀態畫成全螢幕互動介面：即時呈現負載變化，並用鍵盤直接排序、過濾、送訊號，取代反覆敲 <code>ps</code>、<code>df</code>、<code>free</code> 再自行拼湊。在遠端 SSH 情境下，它的關鍵變數是刷新率與頻寬的取捨，因為全螢幕介面每次刷新都會重送整片畫面。</p>
<p>本文承接 <a href="/blog/linux/tools/cli/cli-graphical-tools-overview/" data-link-title="終端機圖形化工具總覽：遠端操作下的 TUI、文字圖表與多工器" data-link-desc="在純文字終端機裡用 ASCII 與製圖字元做出監控儀表板、資料圖表與多視窗操作的工具總覽，並針對 SSH 伺服器、手機平板、低頻寬三種遠端情境給出選型判讀。">終端機圖形化工具總覽</a> 的 TUI 工具脈絡，聚焦系統監控這一支在遠端的實際使用與調校。git 線圖工具（<code>tig</code> / <code>lazygit</code> / <code>gitui</code>）雖然也是 TUI，但屬版控子題，獨立成 <a href="/blog/linux/tools/cli/git-line-graph-tools-for-remote-cli/" data-link-title="遠端 CLI 開發的 git 線圖工具選型：tig、lazygit、gitui 與管線增強" data-link-desc="純 CLI、遠端開發情境下查看 git 分支線圖的工具地景，從 tig 唯讀瀏覽到 lazygit/gitui 操作中樞的定位差異，含選型判準與 lazygit 上手、delta side-by-side diff 設定。">遠端 CLI 開發的 git 線圖工具選型</a>。</p>
<h2 id="htop進程層的標準">htop：進程層的標準</h2>
<p>htop 把進程清單畫成帶 CPU 與記憶體長條的全螢幕視圖，責任是即時看進程並直接操作。它用底部的功能鍵列引導操作，不必背指令。</p>
<table>
  <thead>
      <tr>
          <th>按鍵</th>
          <th>作用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>F3</code></td>
          <td>搜尋進程</td>
      </tr>
      <tr>
          <td><code>F4</code></td>
          <td>過濾（只顯示符合的進程）</td>
      </tr>
      <tr>
          <td><code>F5</code></td>
          <td>樹狀檢視（看父子關係）</td>
      </tr>
      <tr>
          <td><code>F6</code></td>
          <td>選排序欄位</td>
      </tr>
      <tr>
          <td><code>F9</code></td>
          <td>送訊號（殺進程）</td>
      </tr>
      <tr>
          <td><code>F10</code></td>
          <td>離開</td>
      </tr>
  </tbody>
</table>
<p>遠端使用的關鍵是刷新延遲。htop 用 <code>-d</code> 設定刷新間隔，單位是十分之一秒，所以 <code>htop -d 30</code> 是每 3 秒刷新一次。慢速連線下把延遲調大換取畫面不卡、按鍵不延遲，可從 5 秒（<code>htop -d 50</code>）起步，順了再往下調。這個 5 秒是經驗起點、不是測得的閾值，實際依連線 RTT 與終端尺寸調整（後面 btop 與判讀段沿用此基準）。</p>
<h2 id="btop多資源儀表板">btop：多資源儀表板</h2>
<p>btop 把 CPU、記憶體、網路、磁碟畫在同一畫面，並帶歷史曲線與滑鼠操作，責任是一眼總覽多個資源維度的趨勢。相較 htop 偏進程清單，btop 偏向整機儀表板。</p>
<p>刷新率是 btop 在遠端最該調的設定。它的刷新間隔由 <code>update_ms</code> 控制（預設 2000 毫秒），把間隔調短會讓全螢幕重畫更頻繁、在慢速連線吃掉頻寬。調整方式是按 <code>Esc</code> 開 Options 選單改 <code>update_ms</code>，或直接編輯設定檔 <code>~/.config/btop/btop.conf</code> 的 <code>update_ms</code> 值。判讀分界與 htop 相同：連線品質好可用較密的刷新換即時性，品質差就把間隔拉長，慢速連線可從 <code>update_ms 5000</code>（5 秒）起步。</p>
<h2 id="k9skubernetes-叢集導航">k9s：Kubernetes 叢集導航</h2>
<p>k9s 把 <code>kubectl</code> 的查詢與操作做成全螢幕導航介面，責任是讓叢集管理不必逐條敲 <code>kubectl</code> 指令。它用冒號指令切換資源視圖，游標選中資源後用快捷鍵操作。</p>
<blockquote>
<p>安裝與 <code>--refresh</code> 旗標已實機驗證；以下 <code>:pods</code> 等叢集操作需連到 k8s cluster，依官方用法、本機未實機驗證。</p></blockquote>
<p>常見操作是輸入 <code>:pods</code> 看 pod 清單、<code>:svc</code> 看 service，游標停在某個 pod 上按 <code>l</code> 看 log、<code>d</code> 看 describe、<code>s</code> 進 container shell。對遠端管理叢集的情境，它把「查狀態到進去除錯」的流程收進同一畫面，省去反覆切換指令的負擔。k9s 同樣是全螢幕 TUI、會定期輪詢叢集狀態，慢速連線下導航延遲明顯時，可在啟動時用 <code>--refresh</code> 把輪詢間隔（秒）調長。</p>
<h2 id="其他常用-tui-監控">其他常用 TUI 監控</h2>
<p>不同資源維度有各自的專用 TUI，責任聚焦在單一面向。</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>監控對象</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ncdu</code> / <code>gdu</code></td>
          <td>磁碟空間</td>
          <td>掃描目錄並用長條顯示各目錄佔多少空間</td>
      </tr>
      <tr>
          <td><code>ctop</code></td>
          <td>容器</td>
          <td>即時看各 container 的資源佔用</td>
      </tr>
      <tr>
          <td><code>dive</code></td>
          <td>映像層</td>
          <td>逐層分析 Docker image 的大小組成</td>
      </tr>
  </tbody>
</table>
<p>這些工具的共同特性是各管一個維度：磁碟爆了用 <code>ncdu</code> 找出是哪一包、容器資源異常用 <code>ctop</code> 定位、要拆解 image 肥在哪用 <code>dive</code>。遠端排查時依問題維度挑對應工具，比開一個大而全的儀表板更直接。</p>
<p>Docker 相關的兩個工具值得多記一筆。<code>dive</code> 除了 TUI，還有非互動的 <code>--ci</code> 模式：<code>dive &lt;image&gt; --ci</code> 會輸出 image 的 efficiency 與 wasted space，並依門檻判定 pass/fail，適合塞進 CI pipeline 擋住臃腫 image。<code>ctop</code> 的單一容器細節視圖（游標選中按 <code>Enter</code>）會把環境變數明文列出，含資料庫密碼這類敏感值，共享畫面或側錄時要留意。</p>
<h2 id="遠端刷新率與頻寬的取捨">遠端刷新率與頻寬的取捨</h2>
<p>全螢幕 TUI 監控的遠端成本核心在於：每次刷新會重送整片字元矩陣，刷新越密、頻寬負擔越重。慢速連線下會看到畫面延遲、按鍵反應慢。對策是把刷新間隔調長（<code>htop -d</code>、btop 的 <code>update_ms</code>），用更新頻率換流暢度。</p>
<p>判讀分界落在刷新率與監控粒度：連線順暢時用 1–2 秒的密集刷新看即時變化；連線吃緊時把間隔拉到 5 秒以上，或當只盯單一指標時改用一次性的文字趨勢（見 <a href="/blog/linux/tools/cli/ascii-charts-in-terminal/" data-link-title="終端機文字圖表：gnuplot、termgraph、plotext 與 sparkline" data-link-desc="把數值畫成終端機文字圖的工具：gnuplot dumb terminal、termgraph 長條圖、plotext 腳本繪圖、sparkline 與 pipeline 即時更新，以及遠端情境下一次性輸出省頻寬的判讀。">終端機文字圖表</a>）而非全螢幕儀表板。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>把監控擺進可持久化的多工器：<a href="/blog/linux/tools/cli/tmux-persistence-and-basics/" data-link-title="tmux 基礎：遠端 session 持久化與基本操作" data-link-desc="tmux 終端機多工器的遠端使用核心：detach/reattach 讓 session 脫離連線生命週期、prefix key 與 window/pane 操作、手機友善的快捷鍵調校，以及 tmux 與 zellij 的選型對照。">tmux 基礎</a>，斷線後 reattach 回去監控還在跑。</li>
<li>一次性的文字趨勢圖（省頻寬的替代）：<a href="/blog/linux/tools/cli/ascii-charts-in-terminal/" data-link-title="終端機文字圖表：gnuplot、termgraph、plotext 與 sparkline" data-link-desc="把數值畫成終端機文字圖的工具：gnuplot dumb terminal、termgraph 長條圖、plotext 腳本繪圖、sparkline 與 pipeline 即時更新，以及遠端情境下一次性輸出省頻寬的判讀。">終端機文字圖表</a>。</li>
<li>監控的是 web 請求而非系統資源：<a href="/blog/linux/tools/cli/web-server-log-monitoring/" data-link-title="終端機看 nginx 請求：GoAccess、ngxtop 與何時該用 pipeline 而非 TUI" data-link-desc="在終端機即時看 nginx／web 伺服器請求的工具：GoAccess 即時儀表板、ngxtop top 風格，含 log 格式對齊的 gotcha；以及「當下排查用 TUI、持續監控用 metrics pipeline」的使用時機分界。">終端機看 nginx 請求</a>（GoAccess / ngxtop）。</li>
<li>TUI 監控在遠端工具分類中的定位：<a href="/blog/linux/tools/cli/cli-graphical-tools-overview/" data-link-title="終端機圖形化工具總覽：遠端操作下的 TUI、文字圖表與多工器" data-link-desc="在純文字終端機裡用 ASCII 與製圖字元做出監控儀表板、資料圖表與多視窗操作的工具總覽，並針對 SSH 伺服器、手機平板、低頻寬三種遠端情境給出選型判讀。">終端機圖形化工具總覽</a>。</li>
</ul>
]]></content:encoded></item><item><title>終端機圖形化工具總覽：遠端操作下的 TUI、文字圖表與多工器</title><link>https://tarrragon.github.io/blog/linux/tools/cli/cli-graphical-tools-overview/</link><pubDate>Mon, 15 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/tools/cli/cli-graphical-tools-overview/</guid><description>&lt;p>終端機圖形化工具，是用純文字字元（ASCII 與 Unicode 製圖字元）在終端機裡畫出可讀介面的一類程式，承擔的責任是讓遠端操作不必依賴桌面圖形環境就能監控系統、判讀資料與管理多個工作流。它們傳輸的全是文字，所以在頻寬低、連線不穩、只有一支手機的情境下，反而比真正的圖形介面更可靠。&lt;/p>
&lt;p>這類工具常被誤解成「把圖片塞進終端機」。那是另一條技術路線（sixel、kitty 影像協定、&lt;code>chafa&lt;/code> 把 PNG 轉成色塊），依賴特定終端機支援、傳輸量大，在低頻寬遠端會卡。本篇談的是另一條路線：用 &lt;code>─│┌┐└┘&lt;/code> 這類製圖字元、用半形與全形方塊堆出長條圖、用 sparkline 點陣畫趨勢線。畫面本質仍是一段文字，任何能顯示文字的終端機都能呈現。&lt;/p>
&lt;h2 id="為什麼遠端操作特別需要這條路線">為什麼遠端操作特別需要這條路線&lt;/h2>
&lt;p>遠端操作的核心限制是頻寬與連線穩定度，而純文字介面正好把這兩個成本壓到最低。一個全螢幕的監控介面，每次刷新送出的是一整片字元矩陣；若改用影像協定，送出的是一張壓縮點陣圖，資料量通常相差一個量級以上（實際視壓縮率而定）。連線中斷時，文字介面只要重連就能重畫，影像協定則可能因終端機狀態錯亂而花屏。&lt;/p>
&lt;p>這條路線也避開了環境依賴。遠端伺服器通常沒有桌面環境，X11 forwarding 設定繁瑣又吃頻寬；手機上的 SSH app（Termius、Blink、JuiceSSH）能穩定顯示的就是文字。把操作介面建立在文字之上，等於把「能不能用」的前提降到「終端機能顯示字」，這是所有遠端通道都滿足的最低標準。&lt;/p>
&lt;h2 id="六類工具的定位">六類工具的定位&lt;/h2>
&lt;p>終端機圖形化工具依本篇關注的責任分六類，各自解決遠端操作的不同問題。這裡聚焦「監控判讀、資料可視化、多工承載、檔案瀏覽、資料庫存取、訊息佇列存取」六種責任；fuzzy finder（&lt;code>fzf&lt;/code>）等其他互動式 TUI 同屬純文字遠端路線、但責任不同，不在本篇的選型框架內。&lt;/p>
&lt;h3 id="tui-監控與儀表板">TUI 監控與儀表板&lt;/h3>
&lt;p>TUI（Text User Interface）監控工具負責把系統即時狀態畫成全螢幕的互動介面，省去反覆敲 &lt;code>ps&lt;/code>、&lt;code>df&lt;/code>、&lt;code>free&lt;/code> 再自行拼湊的步驟，一眼呈現 CPU、記憶體、磁碟、網路的即時變化。它用製圖字元畫出框線與長條，用顏色標出負載高低，並接受鍵盤操作來排序、過濾、殺進程。&lt;/p>
&lt;p>&lt;code>btop&lt;/code> 與 &lt;code>htop&lt;/code> 是系統層的代表：開起來就是一片帶長條圖的進程清單，可以直接選中進程送訊號。&lt;code>k9s&lt;/code> 把這套搬到 Kubernetes，用同樣的全螢幕互動瀏覽 pod、查 log、進 container。&lt;code>lazygit&lt;/code> 把 git 的暫存、commit、分支操作變成可點選的面板。&lt;code>ncdu&lt;/code> 與 &lt;code>gdu&lt;/code> 掃描磁碟並用長條畫出每個目錄佔多少空間，找出爆掉的是哪一包。&lt;/p>
&lt;p>這類工具的共同判讀訊號是：需求落在「即時狀態 + 立刻操作」、而非「事後分析一段歷史資料」時，TUI 監控是對的選擇。它的邊界在於刷新成本 — 全螢幕重畫在慢速連線上會明顯延遲，這點在後面遠端情境會展開。&lt;/p>
&lt;h3 id="ascii-與文字圖表">ASCII 與文字圖表&lt;/h3>
&lt;p>文字圖表工具負責把一串數值畫成終端機裡的圖，讓趨勢與分布可視化，而不必把資料下載回本機開試算表。它接受標準輸入或檔案的數字，輸出長條圖、折線圖或 sparkline，全部由字元構成。&lt;/p>
&lt;p>&lt;code>gnuplot&lt;/code> 是老牌繪圖工具，設定 &lt;code>set terminal dumb&lt;/code> 就會用 ASCII 畫折線圖，適合畫函數或一段時間序列。&lt;code>termgraph&lt;/code> 吃一份「標籤 + 數值」就畫出橫向長條圖，看各分類佔比很直接。&lt;code>plotext&lt;/code> 是 Python 函式庫，在腳本裡直接畫折線與散點，適合接在資料處理流程後面。&lt;code>youplot&lt;/code>（&lt;code>uplot&lt;/code>）能從 pipeline 即時吃資料畫圖，配合 &lt;code>tail -f&lt;/code> 可以做出滾動更新的監控線。sparkline 類工具（如 &lt;code>spark&lt;/code>）把一串數字壓成一行高低起伏的點陣，塞進狀態列或 log 裡都行。&lt;/p>
&lt;p>這類工具的判讀訊號是：手上已經有一串數值（log 抽出來的延遲、監控匯出的指標、一個查詢的結果），想看形狀而非逐筆讀數字。它跟 TUI 監控的差別在於資料來源 — TUI 監控自己去抓系統即時狀態，文字圖表則是餵什麼畫什麼，適合畫自訂指標與歷史資料。&lt;/p>
&lt;h3 id="終端機多工器">終端機多工器&lt;/h3>
&lt;p>終端機多工器負責在單一連線裡管理多個終端機 session，並讓 session 的生命週期脫離連線本身。它把畫面切成多個 pane、用分頁組織工作流，而最關鍵的是：連線斷了，伺服器上的 session 仍在跑，重連後 attach 回去就接續原狀。&lt;/p>
&lt;p>&lt;code>tmux&lt;/code> 是事實標準，幾乎每台伺服器都裝得到，設定檔成熟、資源佔用低。&lt;code>zellij&lt;/code> 是較新的選擇，預設就有畫面提示（floating pane、操作提示列），對不熟快捷鍵的人上手較快，並內建 layout 設定能一鍵開出固定的多 pane 佈局。&lt;/p>
&lt;p>多工器跟前兩類不同，它本身不畫資料圖，而是承載其他工具的容器：在一個 pane 跑 &lt;code>btop&lt;/code>、另一個 pane 跑 &lt;code>tail -f&lt;/code> 接 sparkline、第三個 pane 留著敲指令。對遠端操作來說，它解決的是連線穩定度問題 — 這是手機與低頻寬情境的核心痛點，下一節展開。&lt;/p>
&lt;h3 id="檔案瀏覽與操作">檔案瀏覽與操作&lt;/h3>
&lt;p>檔案管理器負責把目錄結構與檔案內容做成可導航的互動介面，讓遠端只有終端機時也能像 IDE 側邊欄那樣瀏覽、預覽、搬移檔案，取代反覆 &lt;code>ls&lt;/code>、&lt;code>cd&lt;/code>、&lt;code>cat&lt;/code>。&lt;/p>
&lt;p>&lt;code>broot&lt;/code> 用可展開的樹狀檢視呈現目錄層級，配模糊跳轉適合深層結構；&lt;code>yazi&lt;/code> 與 &lt;code>ranger&lt;/code> 走 Miller 欄狀（並列父目錄、當前目錄、預覽窗），邊瀏覽邊看內容。&lt;/p>
&lt;p>這類工具的判讀訊號是：需求落在檔案層級的導覽與操作，而非系統監控或畫圖。選型與依賴注意事項見 &lt;a href="https://tarrragon.github.io/blog/linux/tools/cli/file-manager-tuis/" data-link-title="終端機檔案管理器：broot、yazi、ranger 的遠端瀏覽與選型" data-link-desc="在純文字終端機用 TUI 檔案管理器像 IDE 側邊欄那樣瀏覽目錄、預覽檔案內容：樹狀（broot）與 Miller 欄狀（yazi/ranger）兩種介面範式的差異，以及遠端 SSH 情境下的選型與依賴注意事項。">終端機檔案管理器&lt;/a>。&lt;/p>
&lt;h3 id="資料庫存取">資料庫存取&lt;/h3>
&lt;p>資料庫客戶端負責把 DB 的 schema、表格與查詢結果做成文字介面，讓遠端只有終端機時也能連到資料庫瀏覽資料、跑查詢，取代把連線資訊餵給桌面 GUI（DBeaver、TablePlus）。&lt;/p>
&lt;p>它分兩種範式：全螢幕 TUI（&lt;code>harlequin&lt;/code> 的 SQL IDE 風、&lt;code>lazysql&lt;/code> 的瀏覽器風）把 schema 樹、編輯器、結果表排進面板；增強型 REPL（&lt;code>pgcli&lt;/code> / &lt;code>litecli&lt;/code>）仍是行式打 SQL、但補上語法高亮與智能補全。&lt;/p>
&lt;p>這類工具的判讀訊號是：需求落在連資料庫做事，而非看系統或檔案。選型與連線注意事項見 &lt;a href="https://tarrragon.github.io/blog/linux/tools/cli/sql-database-clients/" data-link-title="終端機 SQL 客戶端：harlequin、lazysql 與 pgcli/litecli 的選型" data-link-desc="在純文字終端機連資料庫、跑查詢、看結果的客戶端：全螢幕 TUI（harlequin IDE 風、lazysql 瀏覽器風）與增強型 REPL（pgcli/litecli）兩種範式，以及遠端連線的 SSL driver gotcha。">終端機 SQL 客戶端&lt;/a>。&lt;/p>
&lt;h3 id="訊息佇列存取">訊息佇列存取&lt;/h3>
&lt;p>訊息佇列客戶端負責把 broker 的 topic、partition、consumer group 與訊息內容做成文字介面，讓遠端只有終端機時也能瀏覽訊息流、消費單一 topic、看消費進度，取代把連線資訊餵給桌面工具（Conduktor、RedisInsight）。它跟資料庫客戶端的關鍵差異是多半綁單一 broker 協議：Kafka 的 TUI 不認 AMQP、一個工具連多種 broker 是少數例外。&lt;/p>
&lt;p>它同樣分兩種範式：全螢幕 TUI（Kafka 的 &lt;code>kaskade&lt;/code> 看叢集與消費、&lt;code>yozefu&lt;/code> 用查詢撈 record）把 topic 清單與訊息排進面板；增強型 REPL（Redis 的 &lt;code>iredis&lt;/code>）行式打指令、補上補全與型別感知。&lt;/p></description><content:encoded><![CDATA[<p>終端機圖形化工具，是用純文字字元（ASCII 與 Unicode 製圖字元）在終端機裡畫出可讀介面的一類程式，承擔的責任是讓遠端操作不必依賴桌面圖形環境就能監控系統、判讀資料與管理多個工作流。它們傳輸的全是文字，所以在頻寬低、連線不穩、只有一支手機的情境下，反而比真正的圖形介面更可靠。</p>
<p>這類工具常被誤解成「把圖片塞進終端機」。那是另一條技術路線（sixel、kitty 影像協定、<code>chafa</code> 把 PNG 轉成色塊），依賴特定終端機支援、傳輸量大，在低頻寬遠端會卡。本篇談的是另一條路線：用 <code>─│┌┐└┘</code> 這類製圖字元、用半形與全形方塊堆出長條圖、用 sparkline 點陣畫趨勢線。畫面本質仍是一段文字，任何能顯示文字的終端機都能呈現。</p>
<h2 id="為什麼遠端操作特別需要這條路線">為什麼遠端操作特別需要這條路線</h2>
<p>遠端操作的核心限制是頻寬與連線穩定度，而純文字介面正好把這兩個成本壓到最低。一個全螢幕的監控介面，每次刷新送出的是一整片字元矩陣；若改用影像協定，送出的是一張壓縮點陣圖，資料量通常相差一個量級以上（實際視壓縮率而定）。連線中斷時，文字介面只要重連就能重畫，影像協定則可能因終端機狀態錯亂而花屏。</p>
<p>這條路線也避開了環境依賴。遠端伺服器通常沒有桌面環境，X11 forwarding 設定繁瑣又吃頻寬；手機上的 SSH app（Termius、Blink、JuiceSSH）能穩定顯示的就是文字。把操作介面建立在文字之上，等於把「能不能用」的前提降到「終端機能顯示字」，這是所有遠端通道都滿足的最低標準。</p>
<h2 id="六類工具的定位">六類工具的定位</h2>
<p>終端機圖形化工具依本篇關注的責任分六類，各自解決遠端操作的不同問題。這裡聚焦「監控判讀、資料可視化、多工承載、檔案瀏覽、資料庫存取、訊息佇列存取」六種責任；fuzzy finder（<code>fzf</code>）等其他互動式 TUI 同屬純文字遠端路線、但責任不同，不在本篇的選型框架內。</p>
<h3 id="tui-監控與儀表板">TUI 監控與儀表板</h3>
<p>TUI（Text User Interface）監控工具負責把系統即時狀態畫成全螢幕的互動介面，省去反覆敲 <code>ps</code>、<code>df</code>、<code>free</code> 再自行拼湊的步驟，一眼呈現 CPU、記憶體、磁碟、網路的即時變化。它用製圖字元畫出框線與長條，用顏色標出負載高低，並接受鍵盤操作來排序、過濾、殺進程。</p>
<p><code>btop</code> 與 <code>htop</code> 是系統層的代表：開起來就是一片帶長條圖的進程清單，可以直接選中進程送訊號。<code>k9s</code> 把這套搬到 Kubernetes，用同樣的全螢幕互動瀏覽 pod、查 log、進 container。<code>lazygit</code> 把 git 的暫存、commit、分支操作變成可點選的面板。<code>ncdu</code> 與 <code>gdu</code> 掃描磁碟並用長條畫出每個目錄佔多少空間，找出爆掉的是哪一包。</p>
<p>這類工具的共同判讀訊號是：需求落在「即時狀態 + 立刻操作」、而非「事後分析一段歷史資料」時，TUI 監控是對的選擇。它的邊界在於刷新成本 — 全螢幕重畫在慢速連線上會明顯延遲，這點在後面遠端情境會展開。</p>
<h3 id="ascii-與文字圖表">ASCII 與文字圖表</h3>
<p>文字圖表工具負責把一串數值畫成終端機裡的圖，讓趨勢與分布可視化，而不必把資料下載回本機開試算表。它接受標準輸入或檔案的數字，輸出長條圖、折線圖或 sparkline，全部由字元構成。</p>
<p><code>gnuplot</code> 是老牌繪圖工具，設定 <code>set terminal dumb</code> 就會用 ASCII 畫折線圖，適合畫函數或一段時間序列。<code>termgraph</code> 吃一份「標籤 + 數值」就畫出橫向長條圖，看各分類佔比很直接。<code>plotext</code> 是 Python 函式庫，在腳本裡直接畫折線與散點，適合接在資料處理流程後面。<code>youplot</code>（<code>uplot</code>）能從 pipeline 即時吃資料畫圖，配合 <code>tail -f</code> 可以做出滾動更新的監控線。sparkline 類工具（如 <code>spark</code>）把一串數字壓成一行高低起伏的點陣，塞進狀態列或 log 裡都行。</p>
<p>這類工具的判讀訊號是：手上已經有一串數值（log 抽出來的延遲、監控匯出的指標、一個查詢的結果），想看形狀而非逐筆讀數字。它跟 TUI 監控的差別在於資料來源 — TUI 監控自己去抓系統即時狀態，文字圖表則是餵什麼畫什麼，適合畫自訂指標與歷史資料。</p>
<h3 id="終端機多工器">終端機多工器</h3>
<p>終端機多工器負責在單一連線裡管理多個終端機 session，並讓 session 的生命週期脫離連線本身。它把畫面切成多個 pane、用分頁組織工作流，而最關鍵的是：連線斷了，伺服器上的 session 仍在跑，重連後 attach 回去就接續原狀。</p>
<p><code>tmux</code> 是事實標準，幾乎每台伺服器都裝得到，設定檔成熟、資源佔用低。<code>zellij</code> 是較新的選擇，預設就有畫面提示（floating pane、操作提示列），對不熟快捷鍵的人上手較快，並內建 layout 設定能一鍵開出固定的多 pane 佈局。</p>
<p>多工器跟前兩類不同，它本身不畫資料圖，而是承載其他工具的容器：在一個 pane 跑 <code>btop</code>、另一個 pane 跑 <code>tail -f</code> 接 sparkline、第三個 pane 留著敲指令。對遠端操作來說，它解決的是連線穩定度問題 — 這是手機與低頻寬情境的核心痛點，下一節展開。</p>
<h3 id="檔案瀏覽與操作">檔案瀏覽與操作</h3>
<p>檔案管理器負責把目錄結構與檔案內容做成可導航的互動介面，讓遠端只有終端機時也能像 IDE 側邊欄那樣瀏覽、預覽、搬移檔案，取代反覆 <code>ls</code>、<code>cd</code>、<code>cat</code>。</p>
<p><code>broot</code> 用可展開的樹狀檢視呈現目錄層級，配模糊跳轉適合深層結構；<code>yazi</code> 與 <code>ranger</code> 走 Miller 欄狀（並列父目錄、當前目錄、預覽窗），邊瀏覽邊看內容。</p>
<p>這類工具的判讀訊號是：需求落在檔案層級的導覽與操作，而非系統監控或畫圖。選型與依賴注意事項見 <a href="/blog/linux/tools/cli/file-manager-tuis/" data-link-title="終端機檔案管理器：broot、yazi、ranger 的遠端瀏覽與選型" data-link-desc="在純文字終端機用 TUI 檔案管理器像 IDE 側邊欄那樣瀏覽目錄、預覽檔案內容：樹狀（broot）與 Miller 欄狀（yazi/ranger）兩種介面範式的差異，以及遠端 SSH 情境下的選型與依賴注意事項。">終端機檔案管理器</a>。</p>
<h3 id="資料庫存取">資料庫存取</h3>
<p>資料庫客戶端負責把 DB 的 schema、表格與查詢結果做成文字介面，讓遠端只有終端機時也能連到資料庫瀏覽資料、跑查詢，取代把連線資訊餵給桌面 GUI（DBeaver、TablePlus）。</p>
<p>它分兩種範式：全螢幕 TUI（<code>harlequin</code> 的 SQL IDE 風、<code>lazysql</code> 的瀏覽器風）把 schema 樹、編輯器、結果表排進面板；增強型 REPL（<code>pgcli</code> / <code>litecli</code>）仍是行式打 SQL、但補上語法高亮與智能補全。</p>
<p>這類工具的判讀訊號是：需求落在連資料庫做事，而非看系統或檔案。選型與連線注意事項見 <a href="/blog/linux/tools/cli/sql-database-clients/" data-link-title="終端機 SQL 客戶端：harlequin、lazysql 與 pgcli/litecli 的選型" data-link-desc="在純文字終端機連資料庫、跑查詢、看結果的客戶端：全螢幕 TUI（harlequin IDE 風、lazysql 瀏覽器風）與增強型 REPL（pgcli/litecli）兩種範式，以及遠端連線的 SSL driver gotcha。">終端機 SQL 客戶端</a>。</p>
<h3 id="訊息佇列存取">訊息佇列存取</h3>
<p>訊息佇列客戶端負責把 broker 的 topic、partition、consumer group 與訊息內容做成文字介面，讓遠端只有終端機時也能瀏覽訊息流、消費單一 topic、看消費進度，取代把連線資訊餵給桌面工具（Conduktor、RedisInsight）。它跟資料庫客戶端的關鍵差異是多半綁單一 broker 協議：Kafka 的 TUI 不認 AMQP、一個工具連多種 broker 是少數例外。</p>
<p>它同樣分兩種範式：全螢幕 TUI（Kafka 的 <code>kaskade</code> 看叢集與消費、<code>yozefu</code> 用查詢撈 record）把 topic 清單與訊息排進面板；增強型 REPL（Redis 的 <code>iredis</code>）行式打指令、補上補全與型別感知。</p>
<p>這類工具的判讀訊號是：需求落在連 broker 看訊息與消費狀態，而非連資料庫。選型與實機驗證注意事項見 <a href="/blog/linux/tools/cli/message-queue-tui-clients/" data-link-title="終端機訊息佇列客戶端：Kafka 的 kaskade/yozefu/ktea 與 Redis 的 iredis" data-link-desc="在純文字終端機連 broker、瀏覽 topic、消費訊息、檢視 consumer 狀態的客戶端：Kafka 的全螢幕 TUI（kaskade/yozefu/ktea）、Redis 的增強型 REPL（iredis），以及訊息佇列 TUI 多半綁單一 broker 協議這個跟 SQL 客戶端最大的不同。">終端機訊息佇列客戶端</a>。</p>
<h2 id="三種遠端情境的選型判讀">三種遠端情境的選型判讀</h2>
<p>工具選型要回到實際的連線條件，而不只是比對功能清單。以下對應三種常見的遠端情境，各自的判讀重點與陷阱不同。</p>
<h3 id="ssh-連到-linux-伺服器">SSH 連到 Linux 伺服器</h3>
<p>從本機 SSH 進伺服器，連線通常穩定、頻寬足，瓶頸在於操作要連續、不想每次重連都從頭開始。這個情境的核心配置是「多工器打底 + TUI 監控擺上去」：登入後先 <code>tmux attach</code>（沒有就 <code>tmux new</code>），在固定的 pane 佈局裡跑監控與操作。</p>
<p>這裡的判讀重點是把 session 持久化當成預設習慣，而不是等斷線才後悔。即使連線穩定，把長時間任務（build、資料遷移、<code>tail -f</code> 追 log）放進多工器，就能隨時離開再回來。TUI 監控在這個情境幾乎沒有刷新成本顧慮，<code>btop</code> 開最高刷新率也順，互動功能的排序與殺進程都能放手用。</p>
<p>常見陷阱是把多工器與終端機本身的捲動搞混 — 進了 tmux 後，滑鼠捲動預設是 tmux 在管，要進 copy mode 才能往回看歷史。這是上手期最容易卡住的點，值得一開始就把捲動與複製的快捷鍵設順。</p>
<h3 id="手機或平板遠端">手機或平板遠端</h3>
<p>用手機或平板的 SSH app 連線，限制是螢幕小、虛擬鍵盤難敲組合鍵、連線會隨網路切換而中斷。這個情境最該優先的是多工器的持久化能力：手機從 Wi-Fi 切到行動網路、app 切到背景再回來，連線往往已經斷過一次，沒有多工器就等於每次都重來。</p>
<p>工具選型要往「省版面、少快捷鍵」傾斜。<code>zellij</code> 在這裡比 <code>tmux</code> 友善，因為它把操作提示畫在畫面上，不必硬記組合鍵；但 <code>tmux</code> 若已配好觸控友善的快捷鍵也能勝任。TUI 監控要挑版面能縮的 — <code>htop</code> 在窄螢幕下仍可讀，複雜的多欄儀表板則會被擠到看不清。文字圖表在小螢幕反而有優勢，一行 sparkline 不管螢幕多窄都塞得下。</p>
<p>常見陷阱是組合鍵在虛擬鍵盤上難以輸入。多工器的 prefix key（tmux 預設 <code>Ctrl-b</code>）在手機上很難按，值得改綁成單鍵或螢幕上的快捷按鈕；好的 SSH app 通常提供自訂工具列來補這個缺口。</p>
<h3 id="低頻寬或不穩定連線">低頻寬或不穩定連線</h3>
<p>連線慢或會斷時，限制同時來自頻寬與穩定度，兩者要分開處理。穩定度由多工器解決 — 斷線後 session 還在，這點與情境無關地成立。頻寬則直接決定 TUI 監控能不能用得舒服。</p>
<p>這裡最關鍵的判讀是刷新率與重畫成本的取捨。全螢幕 TUI 每次刷新會重送整片畫面，刷新間隔越短、頻寬負擔越重；把刷新率調快、或工具本身刷新較密時，慢速連線上會看到畫面追不上、按鍵延遲。對策是把刷新間隔調長（多數工具支援，例如 <code>btop</code> 在介面裡可調 <code>update_ms</code>、<code>htop</code> 用 <code>-d</code> 設延遲），用較低的更新頻率換流暢的操作。</p>
<p>判讀的分界是即時性與頻寬的取捨：連線品質好就用全螢幕 TUI 的即時性，品質差就退回低頻率的文字輸出。文字圖表在後者特別划算，因為它是一次性輸出而非持續重畫 — 跑一次 <code>termgraph</code> 印出結果就結束，不佔用持續頻寬；需要持續監控時，「低刷新率的單一數值 + 偶爾印一次 sparkline」往往比全螢幕儀表板更實用。</p>
<h2 id="選型判準與下一步">選型判準與下一步</h2>
<p>把這些工具與三種情境收斂成一條判準鏈：先用多工器解決連線斷續（任何遠端情境都先做這步），再依任務選對應工具 — 即時狀態用 TUI 監控、看歷史數值用文字圖表、找檔案用檔案管理器、連資料庫用 SQL 客戶端、連 broker 看訊息用訊息佇列客戶端，最後依連線品質調整刷新率與版面密度。</p>
<p>這條判準對應的具體工具，在本資料夾逐篇展開安裝、設定與遠端調校的細節：</p>
<ul>
<li>TUI 工具：系統監控（<code>btop</code> / <code>htop</code> / <code>k9s</code>）見 <a href="/blog/linux/tools/cli/tui-monitoring-tools/" data-link-title="TUI 監控工具：btop、htop、k9s 的遠端使用與刷新率調校" data-link-desc="全螢幕 TUI 監控工具在遠端 SSH 情境的使用：htop 進程操作、btop 多資源儀表板、k9s 管 Kubernetes，以及慢速連線下刷新率與頻寬的取捨。">TUI 監控工具</a>；web 請求日誌（<code>GoAccess</code> / <code>ngxtop</code>）見 <a href="/blog/linux/tools/cli/web-server-log-monitoring/" data-link-title="終端機看 nginx 請求：GoAccess、ngxtop 與何時該用 pipeline 而非 TUI" data-link-desc="在終端機即時看 nginx／web 伺服器請求的工具：GoAccess 即時儀表板、ngxtop top 風格，含 log 格式對齊的 gotcha；以及「當下排查用 TUI、持續監控用 metrics pipeline」的使用時機分界。">終端機看 nginx 請求</a>；git 線圖（<code>tig</code> / <code>lazygit</code> / <code>gitui</code>）屬版控子題、見 <a href="/blog/linux/tools/cli/git-line-graph-tools-for-remote-cli/" data-link-title="遠端 CLI 開發的 git 線圖工具選型：tig、lazygit、gitui 與管線增強" data-link-desc="純 CLI、遠端開發情境下查看 git 分支線圖的工具地景，從 tig 唯讀瀏覽到 lazygit/gitui 操作中樞的定位差異，含選型判準與 lazygit 上手、delta side-by-side diff 設定。">遠端 CLI 開發的 git 線圖工具選型</a></li>
<li>文字圖表：<a href="/blog/linux/tools/cli/ascii-charts-in-terminal/" data-link-title="終端機文字圖表：gnuplot、termgraph、plotext 與 sparkline" data-link-desc="把數值畫成終端機文字圖的工具：gnuplot dumb terminal、termgraph 長條圖、plotext 腳本繪圖、sparkline 與 pipeline 即時更新，以及遠端情境下一次性輸出省頻寬的判讀。">終端機文字圖表</a>（<code>gnuplot</code> dumb terminal、<code>termgraph</code> 與 <code>plotext</code> 的資料接法、sparkline 接 pipeline）</li>
<li>多工器：<a href="/blog/linux/tools/cli/tmux-persistence-and-basics/" data-link-title="tmux 基礎：遠端 session 持久化與基本操作" data-link-desc="tmux 終端機多工器的遠端使用核心：detach/reattach 讓 session 脫離連線生命週期、prefix key 與 window/pane 操作、手機友善的快捷鍵調校，以及 tmux 與 zellij 的選型對照。">tmux 基礎</a>（持久化與基本操作）；<code>zellij</code> 的 pane 操作見 <a href="/blog/linux/tools/cli/zellij-pane/" data-link-title="Zellij 多終端機操作指南" data-link-desc="Zellij pane 的佈局查看、內容讀取、大小調整等 CLI 操作方式，適合搭配 AI 工具使用。">Zellij 多終端機操作指南</a>、瀏覽器遠端連線見 <a href="/blog/linux/tools/cli/zellij-remote-web-client/" data-link-title="Zellij Web Client 外網連線教學" data-link-desc="讓他人透過瀏覽器連線到指定的 Zellij session，包含 SSL 憑證申請、防火牆設定、Token 管理等完整步驟。">Zellij Web Client 外網連線教學</a></li>
<li>檔案管理：<a href="/blog/linux/tools/cli/file-manager-tuis/" data-link-title="終端機檔案管理器：broot、yazi、ranger 的遠端瀏覽與選型" data-link-desc="在純文字終端機用 TUI 檔案管理器像 IDE 側邊欄那樣瀏覽目錄、預覽檔案內容：樹狀（broot）與 Miller 欄狀（yazi/ranger）兩種介面範式的差異，以及遠端 SSH 情境下的選型與依賴注意事項。">終端機檔案管理器</a>（<code>broot</code> 樹狀、<code>yazi</code> / <code>ranger</code> Miller 欄狀的選型與依賴）</li>
<li>資料庫客戶端：<a href="/blog/linux/tools/cli/sql-database-clients/" data-link-title="終端機 SQL 客戶端：harlequin、lazysql 與 pgcli/litecli 的選型" data-link-desc="在純文字終端機連資料庫、跑查詢、看結果的客戶端：全螢幕 TUI（harlequin IDE 風、lazysql 瀏覽器風）與增強型 REPL（pgcli/litecli）兩種範式，以及遠端連線的 SSL driver gotcha。">終端機 SQL 客戶端</a>（<code>harlequin</code> IDE 風、<code>lazysql</code> 瀏覽器風、<code>pgcli</code> / <code>litecli</code> 增強 REPL）</li>
<li>訊息佇列客戶端：<a href="/blog/linux/tools/cli/message-queue-tui-clients/" data-link-title="終端機訊息佇列客戶端：Kafka 的 kaskade/yozefu/ktea 與 Redis 的 iredis" data-link-desc="在純文字終端機連 broker、瀏覽 topic、消費訊息、檢視 consumer 狀態的客戶端：Kafka 的全螢幕 TUI（kaskade/yozefu/ktea）、Redis 的增強型 REPL（iredis），以及訊息佇列 TUI 多半綁單一 broker 協議這個跟 SQL 客戶端最大的不同。">終端機訊息佇列客戶端</a>（Kafka 的 <code>kaskade</code> / <code>yozefu</code> / <code>ktea</code>、Redis 的 <code>iredis</code>、與綁單一 broker 協議的選型差異）</li>
</ul>
<p>每篇單工具文章會聚焦一個工具在遠端情境下的實際配置，而不是重述官方手冊。先有這份總覽建立選型框架，再依當下的連線條件挑對應的工具深入。</p>
]]></content:encoded></item></channel></rss>