<?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>Collector on Tarragon</title><link>https://tarrragon.github.io/blog/tags/collector/</link><description>Recent content in Collector on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 24 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/collector/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>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>查詢 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>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>模組四：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>規模演進</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>功能分層與 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/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>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>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>OTel Collector 部署模式：agent / gateway / sidecar 與 pipeline 設計</title><link>https://tarrragon.github.io/blog/backend/04-observability/vendors/opentelemetry/collector-deployment-patterns/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/04-observability/vendors/opentelemetry/collector-deployment-patterns/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/vendors/opentelemetry/" data-link-title="OpenTelemetry" data-link-desc="可觀測性開放標準、SDK 與 Collector">OpenTelemetry&lt;/a> 的 vendor deep article，深化 overview「Collector 部署模式」段。初次接觸 OpenTelemetry 的讀者建議先讀 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/vendors/opentelemetry/" data-link-title="OpenTelemetry" data-link-desc="可觀測性開放標準、SDK 與 Collector">OpenTelemetry 服務頁&lt;/a>，再回到本文。指令於 2026-06-16 用 &lt;code>otel/opentelemetry-collector-contrib:0.154.0&lt;/code> 在 docker 實機驗證。&lt;/p>&lt;/blockquote>
&lt;p>應用程式產生的 telemetry 跟最終存放的 backend 之間需要一個中介層 — OTel Collector 就是這個中介。應用只負責用 OTLP 把資料吐給 collector，collector 負責接收、處理、轉發，兩邊解耦。部署這個 collector 的第一個決策是它擺在哪裡（同 host、集中 gateway、還是 pod sidecar），而非配置細節。位置決定了 buffer 能力、enrichment 時機與失效影響面。&lt;/p>
&lt;h2 id="問題情境telemetry-直送-backend-的三個代價">問題情境：telemetry 直送 backend 的三個代價&lt;/h2>
&lt;p>應用程式直接用 vendor SDK 把 telemetry 送到後端，會在規模變大時撞到三個問題。第一是耦合：每個服務都寫死了某個 backend 的 endpoint 與認證，換 backend 要改所有服務重新部署。第二是缺乏 buffer：backend 短暫不可用時，telemetry 直接丟失，因為應用程式不會為了觀測資料保留重試佇列。第三是 enrichment 分散：每個服務各自加 resource attribute、各自做 sampling，標準難統一。&lt;/p>
&lt;p>Collector 把這三件事收斂到一個中介層。應用只認 collector 的 OTLP endpoint，換 backend 只改 collector 配置；collector 有 queue 與重試；enrichment 與 sampling 在 collector 統一做。但這個中介層擺在哪裡，決定了它各自解掉多少。&lt;/p>
&lt;p>服務數少、backend 單一且穩定時，應用直送 backend 是合理起點 — 上述三個代價在小規模下可控。Collector 是規模化後的升級：當 backend 要換、服務數成長到 enrichment 要統一、或 sampling 需求出現時，再引入 collector 補這一層。&lt;/p>
&lt;h2 id="核心概念三種部署位置的責任分工">核心概念：三種部署位置的責任分工&lt;/h2>
&lt;p>Collector 的部署位置分三種，差別在「離應用多近」與「聚合多少來源」。&lt;/p>
&lt;p>Agent 模式把 collector 跟應用程式放在同一個 host 或同一個 K8s node（DaemonSet）。它的責任是做 local buffer 與 host 層 enrichment：應用透過 localhost 把 telemetry 吐給同機的 collector，延遲極低、不跨網路；collector 補上 host name、container id 這類只有在本機才知道的 resource attribute。agent 的價值是「離應用最近」，應用送出 telemetry 後就不必管後續，buffer 與重試由同機 collector 承擔。&lt;/p>
&lt;p>Agent 解了「離應用近、不丟資料」的問題，但它只看得到本機 — 需要全域視野的處理放不進去。Gateway 模式補這一塊：把 collector 集中部署成一個獨立的服務叢集，跨多個 agent 或多個應用接收 telemetry，負責需要全域視野的處理：tail-based sampling（要看完整 trace 才決定採不採）、跨來源的 routing（不同 telemetry 送不同 backend）、集中的 rate limit 與成本控制。gateway 的價值是「集中決策」，把只有匯流後才做得到的處理放在這一層。&lt;/p>
&lt;p>Sidecar 模式在 K8s 把 collector 當成跟應用 pod 同生命週期的 sidecar container。它的責任跟 agent 相似（local buffer、pod 層 enrichment），差別在隔離粒度是 pod 而非 node：比 DaemonSet agent 更貼近單一 pod（共享 pod 網路、隨 pod 起停），適合需要 pod 級獨立配置或強隔離的場景，代價是每個 pod 都多一份 collector 的資源開銷。&lt;/p>
&lt;p>常見部署是兩層組合：agent（DaemonSet）做 local buffer + host enrichment，再把資料送到 gateway 叢集做 tail sampling 與 routing。agent 解掉「離應用近、不丟資料」，gateway 解掉「需要全域視野的處理」，兩層各司其職。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/04-observability/vendors/opentelemetry/" data-link-title="OpenTelemetry" data-link-desc="可觀測性開放標準、SDK 與 Collector">OpenTelemetry</a> 的 vendor deep article，深化 overview「Collector 部署模式」段。初次接觸 OpenTelemetry 的讀者建議先讀 <a href="/blog/backend/04-observability/vendors/opentelemetry/" data-link-title="OpenTelemetry" data-link-desc="可觀測性開放標準、SDK 與 Collector">OpenTelemetry 服務頁</a>，再回到本文。指令於 2026-06-16 用 <code>otel/opentelemetry-collector-contrib:0.154.0</code> 在 docker 實機驗證。</p></blockquote>
<p>應用程式產生的 telemetry 跟最終存放的 backend 之間需要一個中介層 — OTel Collector 就是這個中介。應用只負責用 OTLP 把資料吐給 collector，collector 負責接收、處理、轉發，兩邊解耦。部署這個 collector 的第一個決策是它擺在哪裡（同 host、集中 gateway、還是 pod sidecar），而非配置細節。位置決定了 buffer 能力、enrichment 時機與失效影響面。</p>
<h2 id="問題情境telemetry-直送-backend-的三個代價">問題情境：telemetry 直送 backend 的三個代價</h2>
<p>應用程式直接用 vendor SDK 把 telemetry 送到後端，會在規模變大時撞到三個問題。第一是耦合：每個服務都寫死了某個 backend 的 endpoint 與認證，換 backend 要改所有服務重新部署。第二是缺乏 buffer：backend 短暫不可用時，telemetry 直接丟失，因為應用程式不會為了觀測資料保留重試佇列。第三是 enrichment 分散：每個服務各自加 resource attribute、各自做 sampling，標準難統一。</p>
<p>Collector 把這三件事收斂到一個中介層。應用只認 collector 的 OTLP endpoint，換 backend 只改 collector 配置；collector 有 queue 與重試；enrichment 與 sampling 在 collector 統一做。但這個中介層擺在哪裡，決定了它各自解掉多少。</p>
<p>服務數少、backend 單一且穩定時，應用直送 backend 是合理起點 — 上述三個代價在小規模下可控。Collector 是規模化後的升級：當 backend 要換、服務數成長到 enrichment 要統一、或 sampling 需求出現時，再引入 collector 補這一層。</p>
<h2 id="核心概念三種部署位置的責任分工">核心概念：三種部署位置的責任分工</h2>
<p>Collector 的部署位置分三種，差別在「離應用多近」與「聚合多少來源」。</p>
<p>Agent 模式把 collector 跟應用程式放在同一個 host 或同一個 K8s node（DaemonSet）。它的責任是做 local buffer 與 host 層 enrichment：應用透過 localhost 把 telemetry 吐給同機的 collector，延遲極低、不跨網路；collector 補上 host name、container id 這類只有在本機才知道的 resource attribute。agent 的價值是「離應用最近」，應用送出 telemetry 後就不必管後續，buffer 與重試由同機 collector 承擔。</p>
<p>Agent 解了「離應用近、不丟資料」的問題，但它只看得到本機 — 需要全域視野的處理放不進去。Gateway 模式補這一塊：把 collector 集中部署成一個獨立的服務叢集，跨多個 agent 或多個應用接收 telemetry，負責需要全域視野的處理：tail-based sampling（要看完整 trace 才決定採不採）、跨來源的 routing（不同 telemetry 送不同 backend）、集中的 rate limit 與成本控制。gateway 的價值是「集中決策」，把只有匯流後才做得到的處理放在這一層。</p>
<p>Sidecar 模式在 K8s 把 collector 當成跟應用 pod 同生命週期的 sidecar container。它的責任跟 agent 相似（local buffer、pod 層 enrichment），差別在隔離粒度是 pod 而非 node：比 DaemonSet agent 更貼近單一 pod（共享 pod 網路、隨 pod 起停），適合需要 pod 級獨立配置或強隔離的場景，代價是每個 pod 都多一份 collector 的資源開銷。</p>
<p>常見部署是兩層組合：agent（DaemonSet）做 local buffer + host enrichment，再把資料送到 gateway 叢集做 tail sampling 與 routing。agent 解掉「離應用近、不丟資料」，gateway 解掉「需要全域視野的處理」，兩層各司其職。</p>
<h2 id="pipeline-模型receivers--processors--exporters">pipeline 模型：receivers / processors / exporters</h2>
<p>不論擺在哪個位置，collector 的內部都是同一個 pipeline 模型：telemetry 從 receivers 進來、經過 processors 加工、由 exporters 送出。三者用 <code>service.pipelines</code> 依訊號類型（traces / metrics / logs）串接。以下是最小可驗證配置，三個區塊（receivers / processors / exporters）對應 pipeline 的三個階段，各自職責在後面逐段說明。這份配置在 docker 驗證過可正常啟動並端到端流通（<code>validate --config</code> 回傳 0、送 5 條 trace 後 debug exporter 完整輸出 spans）：</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">receivers</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">otlp</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">protocols</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">grpc</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">endpoint</span><span class="p">:</span><span class="w"> </span><span class="m">0.0.0.0</span><span class="p">:</span><span class="m">4317</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">processors</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">memory_limiter</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">check_interval</span><span class="p">:</span><span class="w"> </span><span class="l">1s</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">limit_mib</span><span class="p">:</span><span class="w"> </span><span class="m">256</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">spike_limit_mib</span><span class="p">:</span><span class="w"> </span><span class="m">64</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">batch</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">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">13</span><span class="cl"><span class="w">    </span><span class="nt">send_batch_size</span><span class="p">:</span><span class="w"> </span><span class="m">1024</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">exporters</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">debug</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">verbosity</span><span class="p">:</span><span class="w"> </span><span class="l">detailed</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">service</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="nt">pipelines</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="nt">traces</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">receivers</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">otlp]</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">processors</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">memory_limiter, batch]</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">exporters</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">debug]</span></span></span></code></pre></div><p>receivers 定義「資料怎麼進來」，OTLP（gRPC 4317 / HTTP 4318）是標準入口。processors 定義「資料怎麼加工」，順序有意義：<code>memory_limiter</code> 放最前面，先擋住記憶體爆掉；<code>batch</code> 放後面，把零散 span 攢成批次再送，降低下游請求數。此處 256 / 64 MiB 是 demo 用量，production 應依 container memory limit 按比例設定（常見做法是 limit_mib 設為 container memory 的 80%、spike 設為 limit 的 20-25%）。exporters 定義「資料送到哪」，正式環境會是 OTLP 到 backend 或某 vendor exporter，這裡用 <code>debug</code> 驗證流通。service.pipelines 才是真正生效的接線：只有被掛進某個 pipeline 的元件才會運作，定義了卻沒掛進 pipeline 的元件不生效。</p>
<p>processor 順序是常見踩雷點。<code>memory_limiter</code> 要排在第一個，讓它在資料進入後續 processor 前就有機會審查與拒收；<code>batch</code> 排在它之後，因為如果 batch 先跑，telemetry 會先在 batch processor 累積成大批，等觸發記憶體限制時壓力已經更高、拒收效果下降。需要 sampling 時，head sampling 可以放 agent 層的 pipeline，tail sampling 必須放 gateway 層（它要匯流完整 trace），且同一 trace 的所有 span 要路由到同一個 gateway 實例（用 trace-id 維度的 load balancing exporter），否則各 gateway 節點各看片段、tail 決策仍不完整。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<p>Collector 失效的影響面取決於部署模式，這是選位置時要先想清楚的。agent 模式下，單一 node 的 collector 掛掉只影響該 node 的應用，且應用送往 localhost 失敗可以 fail-fast；gateway 模式下，gateway 叢集掛掉會影響所有上游 agent，因此 gateway 必須多副本 + 負載均衡，不能單點；sidecar 模式下，失效影響面比 agent 更窄（只影響同 pod 的應用），但每個 pod 各自是獨立失效點，pod 數多時同時出狀況的機率也高。演練時要分別注入「單 agent 掛」與「gateway 叢集不可用」，確認前者影響被局限、後者有 agent 層 buffer 兜著。</p>
<p>記憶體壓力是 collector 最常見的故障。telemetry 流入速度超過 exporter 送出速度時，資料在 collector 內累積、記憶體上升，沒有保護會 OOM 被 kill、整段 telemetry 全丟。<code>memory_limiter</code> processor 是這道防線，它定期（<code>check_interval</code>）檢查記憶體並用兩個閾值分級反應：記憶體超過軟上限（<code>limit_mib</code> 減去 <code>spike_limit_mib</code>）時強制觸發 GC 並開始拒收，給回收一個緩衝區間；超過硬上限（<code>limit_mib</code>）時全面拒收新資料。只設 <code>limit_mib</code>、不設 <code>spike_limit_mib</code> 是不完整的配置，等於沒有軟性緩衝、直接撞硬牆。演練時用高於 exporter 吞吐的速率灌資料，確認 memory_limiter 在軟上限就介入、collector 存活，而不是 OOM。</p>
<p>Backpressure 的傳遞要驗證到底。當 backend 變慢、exporter queue 滿，collector 的 OTLP receiver 會回壓給上游（gRPC 層用 resource-exhausted 拒收）。在 agent 模式這個回壓會傳到應用的 OTLP exporter，應用 SDK 的 queue 也會滿——此時 SDK 的反應取決於 exporter 配置，要確認 queue-full 策略設為 drop 而非 block，讓 telemetry 被丟棄而非阻塞業務執行緒（各語言 SDK 預設不同，不能假設一定是 drop）。演練要確認「backend 慢 → collector 回壓 → 應用丟 telemetry 但業務不受影響」這條鏈成立，避免觀測系統的壓力反噬主流程。</p>
<table>
  <thead>
      <tr>
          <th>觀察訊號</th>
          <th>判讀</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>collector 容器頻繁 OOM restart</td>
          <td>memory_limiter 閾值過高或未啟用</td>
          <td>調低 limit_mib、確認 spike_limit_mib 有設</td>
      </tr>
      <tr>
          <td>exporter queue depth 持續飽和</td>
          <td>下游 backend 回應慢或不可用</td>
          <td>查 backend 狀態、確認 exporter retry 與 timeout 設定</td>
      </tr>
      <tr>
          <td>receiver refused spans 計數上升</td>
          <td>memory_limiter 啟動拒收、collector 處於壓力狀態</td>
          <td>查上游流量是否異常、考慮擴容 gateway 或調降 sampling</td>
      </tr>
      <tr>
          <td>gateway 全部不可用、agent buffer 開始丟棄</td>
          <td>全域 telemetry 中斷</td>
          <td>確認 gateway 多副本與負載均衡、agent 的 queue 與 drop 策略</td>
      </tr>
      <tr>
          <td>telemetry 到 backend 有延遲但不丟失</td>
          <td>batch processor 正常攢批</td>
          <td>正常行為、確認 batch timeout 符合預期</td>
      </tr>
  </tbody>
</table>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>agent 與 gateway 的成本曲線不同，選型要對著規模看。agent（DaemonSet）的成本是「每個 node 一份 collector」的固定開銷：node 多時總開銷隨 node 數線性成長，但每份 collector 只處理本機流量、單份負載可控。gateway 的成本是「集中叢集」：份數少但每份要扛匯流後的總流量，要按總 telemetry 吞吐量做容量規劃與水平擴展。</p>
<p>兩層架構的成本判讀是：agent 層用最小配置（夠做 buffer + enrichment 即可，<code>limit_mib</code> 設小），把重處理（tail sampling、大量 routing）集中到 gateway，讓 gateway 的擴展跟總流量綁定、agent 的開銷跟 node 數綁定。把 tail sampling 誤放在 agent 層是常見的成本錯誤——agent 看不到完整 trace、做不了正確的 tail sampling，還白白吃掉每個 node 的記憶體。</p>
<p>gateway 層的 processor 是攔截高 cardinality attribute 的有效位置：在 telemetry 流入 backend 前用 <code>attributes</code> / <code>transform</code> processor 把高 cardinality label（user id、request id 當 metric label）移除或降維，比讓它流到 backend 後才治理便宜。高 cardinality 的 attribute 會在下游 backend 炸開成本，是另一條要在 collector 攔截的成本線。這條跟 <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> 對齊。</p>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>Collector 部署模式是 OTel 落地的第一個決策，它的下游是 sampling 策略與 backend 選型。決定了 agent + gateway 兩層後，tail sampling 的設計接到 gateway 層的 pipeline；exporter 指向哪個 backend 則回到 <a href="/blog/backend/04-observability/vendors/opentelemetry/#%e4%bd%95%e6%99%82%e6%94%b9%e8%b5%b0%e5%85%b6%e4%bb%96%e6%9c%8d%e5%8b%99" data-link-title="OpenTelemetry" data-link-desc="可觀測性開放標準、SDK 與 Collector">何時改走其他服務</a> 的 vendor portability 判讀。</p>
<p>pipeline 的訊號治理與資料品質回到 <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 分層治理">4.11 Telemetry Pipeline 架構</a> 與 <a href="/blog/backend/04-observability/telemetry-data-quality/" data-link-title="4.17 Telemetry Data Quality" data-link-desc="把 missing signal、schema drift、sampling bias 與 timestamp skew 變成資料品質問題">4.17 Telemetry Data Quality</a>；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>。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/04-observability/vendors/opentelemetry/" data-link-title="OpenTelemetry" data-link-desc="可觀測性開放標準、SDK 與 Collector">OpenTelemetry 服務頁</a></li>
<li><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 分層治理">4.11 Telemetry Pipeline 架構</a></li>
<li><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></li>
<li><a href="/blog/backend/04-observability/tracing-context/" data-link-title="4.3 tracing 與 context link" data-link-desc="整理 trace id、span 與跨服務 context propagation">4.3 tracing 與 context link</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>讀寫分離與查詢擴展</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></channel></rss>