<?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>模組五：平台適配 on Tarragon</title><link>https://tarrragon.github.io/blog/devops/monitoring/05-platform-adaptation/</link><description>Recent content in 模組五：平台適配 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 19 Jun 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/devops/monitoring/05-platform-adaptation/index.xml" rel="self" type="application/rss+xml"/><item><title>JS/TS 平台適配</title><link>https://tarrragon.github.io/blog/devops/monitoring/05-platform-adaptation/js-ts-platform/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/monitoring/05-platform-adaptation/js-ts-platform/</guid><description>&lt;p>瀏覽器環境中的監控 SDK 面臨三個平台特有的限制：跨域請求被 CORS 攔截、Service Worker 可以攔截和修改請求、SPA 的路由變換不觸發頁面載入事件。第一項的成因是瀏覽器預設不讓一個來源讀到另一個來源的回應，機制與它擋的到底是什麼見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/same-origin-policy/" data-link-title="Same-Origin Policy" data-link-desc="前端送得出請求卻拿不到回應、或要判斷跨站防護的前提還在不在時，用來定位瀏覽器隔離的是讀取而非送出">same-origin policy&lt;/a>。每個限制需要 SDK 在設計層面做適配。&lt;/p>
&lt;h2 id="cors-限制">CORS 限制&lt;/h2>
&lt;p>瀏覽器的同源政策限制網頁讀取不同 origin 的回應；collector 和網頁不在同一個 origin（protocol、domain、port 有任一不同）時，SDK 送出的請求受 CORS 規則約束。跨域請求要不要先送 preflight OPTIONS，由請求的方法與標頭決定：POST 的 Content-Type 是 CORS 安全清單內的值（&lt;code>text/plain&lt;/code>、&lt;code>application/x-www-form-urlencoded&lt;/code>、&lt;code>multipart/form-data&lt;/code>）、而且沒有加自訂標頭時，不送 preflight；Content-Type 是 &lt;code>application/json&lt;/code> 這類清單外的值時，瀏覽器先送 preflight，collector 回應允許之後才送出正式的請求。&lt;/p>
&lt;p>SDK 端有兩種送法：&lt;/p>
&lt;p>&lt;code>navigator.sendBeacon(url, data)&lt;/code> 的用途是頁面進入背景或關閉時仍要送出的資料：Beacon 規範要求瀏覽器在頁面的 &lt;code>visibilityState&lt;/code> 變成 &lt;code>hidden&lt;/code> 時立即送出排隊中的 beacon，所以 flush 的程式註冊在 &lt;code>visibilitychange&lt;/code> 事件上，比註冊在 &lt;code>unload&lt;/code> 事件上可靠。它的 CORS 行為照上一段的規則：傳入資料的型別是安全清單內的 Content-Type 時以 no-cors 模式送出、不送 preflight；傳入 &lt;code>application/json&lt;/code> 型別的 Blob 時要先 preflight。beacon 一律帶 credentials（Cookie），所以要 preflight 的那一種情形，collector 的 &lt;code>Access-Control-Allow-Origin&lt;/code> 要寫出網頁的 origin、不能用 &lt;code>*&lt;/code>，並回 &lt;code>Access-Control-Allow-Credentials: true&lt;/code>，否則瀏覽器的 CORS 檢查判定失敗。&lt;/p>
&lt;p>sendBeacon 的限制：一律用 POST 送出，不能指定請求方法與其他標頭（Content-Type 由傳入資料的型別決定）；payload 與 fetch 的 keepalive 請求共用同一份在途上限（Fetch 規範定為 64 KiB），超過時 sendBeacon 回傳 false、不送出；沒有回應 — 送出後無法知道 server 是否收到。&lt;/p>
&lt;p>&lt;code>fetch&lt;/code> 用在需要讀取回應、或送出超過 keepalive 上限的大 payload 時。以 JSON 送出時 collector 要回 &lt;code>Access-Control-Allow-Origin&lt;/code>、&lt;code>Access-Control-Allow-Methods: POST&lt;/code>、&lt;code>Access-Control-Allow-Headers: Content-Type&lt;/code>；請求另外帶 Cookie（&lt;code>credentials: 'include'&lt;/code>）時，&lt;code>Access-Control-Allow-Origin&lt;/code> 同樣要寫出網頁的 origin，並加 &lt;code>Access-Control-Allow-Credentials: true&lt;/code>。fetch 加上 &lt;code>keepalive: true&lt;/code> 也能在頁面關閉時繼續送出，與 sendBeacon 共用 64 KiB 的上限。&lt;/p>
&lt;h2 id="service-worker-攔截">Service Worker 攔截&lt;/h2>
&lt;p>依 Fetch 規範，頁面送出的 fetch、XHR 與 sendBeacon 請求，都會觸發控制這個頁面的 Service Worker 的 fetch 事件（請求預設的 service-workers mode 是 &lt;code>all&lt;/code>，Beacon 規範沒有改它）；瀏覽器實作是否對 sendBeacon 這類 keepalive 請求派送事件，以各瀏覽器的文件為準。監控請求在這一層會遇到什麼問題，取決於 SDK 用哪一種請求方法送資料：HTTP 快取與 Service Worker 的 Cache API，規則都按請求方法區分，而方法是由要送的資料形狀決定的。&lt;/p>
&lt;p>&lt;strong>GET 像素&lt;/strong>：把事件欄位放進網址的查詢參數，用 &lt;code>new Image().src&lt;/code> 或 GET 請求送出。選它的需求是相容性，以及跨網域不必 preflight（沒有 &lt;code>crossorigin&lt;/code> 屬性的圖片請求以 no-cors 模式送出），代價是只能帶少量欄位、受網址長度限制。GET 依 HTTP 語意可以被快取（&lt;a href="https://tarrragon.github.io/blog/backend/12-http-caching/freshness-lifetime/" data-link-title="12.2 新鮮度期限的計算：max-age、s-maxage、Expires 與啟發式期限" data-link-desc="快取在不詢問 origin 的情況下可以直接使用一份副本多久：新鮮度期限的取用順序、Age 標頭與副本年齡的計算、沒有明確期限時規範允許的啟發式期限，以及 Chromium、Firefox、Varnish、nginx 在啟發式期限上的差異">12.2 新鮮度期限的計算：max-age、s-maxage、Expires 與啟發式期限&lt;/a>），同一個網址的事件可能在三個位置被直接回應、沒有送到 collector，三個位置各有自己的處置：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>瀏覽器的 HTTP 快取與轉送代理&lt;/strong>：collector 對這個路徑回 &lt;code>Cache-Control: no-store&lt;/code>；用 fetch 送時另加 &lt;code>cache: 'no-store'&lt;/code>，讓瀏覽器的 HTTP 快取不讀也不存（快取模式見 &lt;a href="https://tarrragon.github.io/blog/backend/12-http-caching/browser-cache-behavior/" data-link-title="12.7 瀏覽器端的快取行為：重新整理、強制重新整理與上一頁" data-link-desc="瀏覽器在不同操作下怎麼使用自己的 HTTP 快取：WHATWG Fetch 規範定義的快取模式、一般瀏覽、重新整理與強制重新整理各自送出的請求、請求端快取指令對中介快取的效力，以及與 HTTP 快取分開運作的 back/forward cache">12.7 瀏覽器端的快取行為：重新整理、強制重新整理與上一頁&lt;/a> 的〈Fetch 規範的快取模式〉）。&lt;/li>
&lt;li>&lt;strong>Service Worker 用 Cache API 存下的回應&lt;/strong>：Cache API 不看 &lt;code>Cache-Control&lt;/code> 標頭，也不看 fetch 的 &lt;code>cache&lt;/code> 選項，要由 Service Worker 對 collector 的路徑不呼叫 &lt;code>event.respondWith()&lt;/code>，讓請求照常送往網路。&lt;/li>
&lt;li>&lt;strong>三個位置一起避開&lt;/strong>：每次請求帶唯一的查詢參數（例如 &lt;code>?_t=&amp;lt;時間戳&amp;gt;&lt;/code>），讓網址不同、對不上任何保存的副本。Service Worker 用 &lt;code>cache.match&lt;/code> 比對時若設了 &lt;code>ignoreSearch&lt;/code>，查詢參數不參與比對，這個方法在那裡無效。用 &lt;code>Image&lt;/code> 送時沒有 &lt;code>cache&lt;/code> 選項可設，唯一的查詢參數是 SDK 端能控制的方法。&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>POST&lt;/strong>（sendBeacon、fetch、XHR）：把錯誤堆疊、操作紀錄、效能數據這類結構化資料放在請求的 body。選它的需求是資料量與結構，以及頁面關閉時仍要送出。RFC 9110 §9.3.3 寫明 POST 請求不會由快取裡保存的 POST 回應回答（「a POST request cannot be satisfied by a cached POST response」）；Service Worker 規範的 Cache API 也只接受 GET：&lt;code>cache.put&lt;/code> 對 POST 回傳以 TypeError 拒絕的 promise，&lt;code>cache.match&lt;/code> 在沒有設 &lt;code>ignoreMethod&lt;/code> 時對不到任何副本。所以「監控請求被快取、沒有送到 collector」不會發生在這種送法上，&lt;code>no-store&lt;/code> 與唯一查詢參數對它沒有作用。&lt;/p></description><content:encoded><![CDATA[<p>瀏覽器環境中的監控 SDK 面臨三個平台特有的限制：跨域請求被 CORS 攔截、Service Worker 可以攔截和修改請求、SPA 的路由變換不觸發頁面載入事件。第一項的成因是瀏覽器預設不讓一個來源讀到另一個來源的回應，機制與它擋的到底是什麼見 <a href="/blog/backend/knowledge-cards/same-origin-policy/" data-link-title="Same-Origin Policy" data-link-desc="前端送得出請求卻拿不到回應、或要判斷跨站防護的前提還在不在時，用來定位瀏覽器隔離的是讀取而非送出">same-origin policy</a>。每個限制需要 SDK 在設計層面做適配。</p>
<h2 id="cors-限制">CORS 限制</h2>
<p>瀏覽器的同源政策限制網頁讀取不同 origin 的回應；collector 和網頁不在同一個 origin（protocol、domain、port 有任一不同）時，SDK 送出的請求受 CORS 規則約束。跨域請求要不要先送 preflight OPTIONS，由請求的方法與標頭決定：POST 的 Content-Type 是 CORS 安全清單內的值（<code>text/plain</code>、<code>application/x-www-form-urlencoded</code>、<code>multipart/form-data</code>）、而且沒有加自訂標頭時，不送 preflight；Content-Type 是 <code>application/json</code> 這類清單外的值時，瀏覽器先送 preflight，collector 回應允許之後才送出正式的請求。</p>
<p>SDK 端有兩種送法：</p>
<p><code>navigator.sendBeacon(url, data)</code> 的用途是頁面進入背景或關閉時仍要送出的資料：Beacon 規範要求瀏覽器在頁面的 <code>visibilityState</code> 變成 <code>hidden</code> 時立即送出排隊中的 beacon，所以 flush 的程式註冊在 <code>visibilitychange</code> 事件上，比註冊在 <code>unload</code> 事件上可靠。它的 CORS 行為照上一段的規則：傳入資料的型別是安全清單內的 Content-Type 時以 no-cors 模式送出、不送 preflight；傳入 <code>application/json</code> 型別的 Blob 時要先 preflight。beacon 一律帶 credentials（Cookie），所以要 preflight 的那一種情形，collector 的 <code>Access-Control-Allow-Origin</code> 要寫出網頁的 origin、不能用 <code>*</code>，並回 <code>Access-Control-Allow-Credentials: true</code>，否則瀏覽器的 CORS 檢查判定失敗。</p>
<p>sendBeacon 的限制：一律用 POST 送出，不能指定請求方法與其他標頭（Content-Type 由傳入資料的型別決定）；payload 與 fetch 的 keepalive 請求共用同一份在途上限（Fetch 規範定為 64 KiB），超過時 sendBeacon 回傳 false、不送出；沒有回應 — 送出後無法知道 server 是否收到。</p>
<p><code>fetch</code> 用在需要讀取回應、或送出超過 keepalive 上限的大 payload 時。以 JSON 送出時 collector 要回 <code>Access-Control-Allow-Origin</code>、<code>Access-Control-Allow-Methods: POST</code>、<code>Access-Control-Allow-Headers: Content-Type</code>；請求另外帶 Cookie（<code>credentials: 'include'</code>）時，<code>Access-Control-Allow-Origin</code> 同樣要寫出網頁的 origin，並加 <code>Access-Control-Allow-Credentials: true</code>。fetch 加上 <code>keepalive: true</code> 也能在頁面關閉時繼續送出，與 sendBeacon 共用 64 KiB 的上限。</p>
<h2 id="service-worker-攔截">Service Worker 攔截</h2>
<p>依 Fetch 規範，頁面送出的 fetch、XHR 與 sendBeacon 請求，都會觸發控制這個頁面的 Service Worker 的 fetch 事件（請求預設的 service-workers mode 是 <code>all</code>，Beacon 規範沒有改它）；瀏覽器實作是否對 sendBeacon 這類 keepalive 請求派送事件，以各瀏覽器的文件為準。監控請求在這一層會遇到什麼問題，取決於 SDK 用哪一種請求方法送資料：HTTP 快取與 Service Worker 的 Cache API，規則都按請求方法區分，而方法是由要送的資料形狀決定的。</p>
<p><strong>GET 像素</strong>：把事件欄位放進網址的查詢參數，用 <code>new Image().src</code> 或 GET 請求送出。選它的需求是相容性，以及跨網域不必 preflight（沒有 <code>crossorigin</code> 屬性的圖片請求以 no-cors 模式送出），代價是只能帶少量欄位、受網址長度限制。GET 依 HTTP 語意可以被快取（<a href="/blog/backend/12-http-caching/freshness-lifetime/" data-link-title="12.2 新鮮度期限的計算：max-age、s-maxage、Expires 與啟發式期限" data-link-desc="快取在不詢問 origin 的情況下可以直接使用一份副本多久：新鮮度期限的取用順序、Age 標頭與副本年齡的計算、沒有明確期限時規範允許的啟發式期限，以及 Chromium、Firefox、Varnish、nginx 在啟發式期限上的差異">12.2 新鮮度期限的計算：max-age、s-maxage、Expires 與啟發式期限</a>），同一個網址的事件可能在三個位置被直接回應、沒有送到 collector，三個位置各有自己的處置：</p>
<ul>
<li><strong>瀏覽器的 HTTP 快取與轉送代理</strong>：collector 對這個路徑回 <code>Cache-Control: no-store</code>；用 fetch 送時另加 <code>cache: 'no-store'</code>，讓瀏覽器的 HTTP 快取不讀也不存（快取模式見 <a href="/blog/backend/12-http-caching/browser-cache-behavior/" data-link-title="12.7 瀏覽器端的快取行為：重新整理、強制重新整理與上一頁" data-link-desc="瀏覽器在不同操作下怎麼使用自己的 HTTP 快取：WHATWG Fetch 規範定義的快取模式、一般瀏覽、重新整理與強制重新整理各自送出的請求、請求端快取指令對中介快取的效力，以及與 HTTP 快取分開運作的 back/forward cache">12.7 瀏覽器端的快取行為：重新整理、強制重新整理與上一頁</a> 的〈Fetch 規範的快取模式〉）。</li>
<li><strong>Service Worker 用 Cache API 存下的回應</strong>：Cache API 不看 <code>Cache-Control</code> 標頭，也不看 fetch 的 <code>cache</code> 選項，要由 Service Worker 對 collector 的路徑不呼叫 <code>event.respondWith()</code>，讓請求照常送往網路。</li>
<li><strong>三個位置一起避開</strong>：每次請求帶唯一的查詢參數（例如 <code>?_t=&lt;時間戳&gt;</code>），讓網址不同、對不上任何保存的副本。Service Worker 用 <code>cache.match</code> 比對時若設了 <code>ignoreSearch</code>，查詢參數不參與比對，這個方法在那裡無效。用 <code>Image</code> 送時沒有 <code>cache</code> 選項可設，唯一的查詢參數是 SDK 端能控制的方法。</li>
</ul>
<p><strong>POST</strong>（sendBeacon、fetch、XHR）：把錯誤堆疊、操作紀錄、效能數據這類結構化資料放在請求的 body。選它的需求是資料量與結構，以及頁面關閉時仍要送出。RFC 9110 §9.3.3 寫明 POST 請求不會由快取裡保存的 POST 回應回答（「a POST request cannot be satisfied by a cached POST response」）；Service Worker 規範的 Cache API 也只接受 GET：<code>cache.put</code> 對 POST 回傳以 TypeError 拒絕的 promise，<code>cache.match</code> 在沒有設 <code>ignoreMethod</code> 時對不到任何副本。所以「監控請求被快取、沒有送到 collector」不會發生在這種送法上，<code>no-store</code> 與唯一查詢參數對它沒有作用。</p>
<p>POST 的風險在 Service Worker 的程式本身：fetch 事件仍然會收到這個請求。常見的寫法是先 <code>fetch(event.request)</code>，再把回應用 <code>cache.put</code> 存進快取；遇到 POST 時，put 被拒絕的 promise 若接在 <code>respondWith()</code> 的鏈上，頁面拿到網路錯誤，而這時請求通常已經送到 collector，SDK 若因此重送，collector 會收到重複的事件。離線優先的處理程式若在網路失敗時回一個預設的回應，SDK 拿到的是假的成功。處置與 GET 的那一條相同：Service Worker 對非 GET 的請求、或 collector 的路徑不呼叫 <code>respondWith()</code>。</p>
<p>兩種送法的差別在於：POST 不需要防快取的處置，GET 除了防快取的處置，也要 Service Worker 放行。檢查時先確認 SDK 實際送出的請求方法：在瀏覽器開發者工具的 Network 面板點開那一筆請求，Headers 分頁的 Request Method 寫著方法。</p>
<p>如果 SDK 本身提供 Service Worker 模組（在 Service Worker 內攔截 error），需要注意 Service Worker 的生命週期和頁面不同 — Service Worker 可能在頁面關閉後仍在執行，也可能在空閒時被瀏覽器終止。</p>
<h2 id="spa-路由變換偵測">SPA 路由變換偵測</h2>
<p>Single Page Application 的路由變換（React Router、Vue Router、Angular Router）不觸發頁面重新載入。從監控角度看，使用者在不同「頁面」之間切換，但 <code>window.onload</code> 只在首次載入時觸發一次。</p>
<p>SDK 需要偵測 SPA 路由變換來記錄 <code>lifecycle.view.change</code> 事件。偵測方式：</p>
<p><code>History API</code> 攔截：monkey-patch <code>history.pushState</code> 和 <code>history.replaceState</code>，在呼叫前後記錄路由變換。同時監聽 <code>popstate</code> 事件處理瀏覽器的上一頁/下一頁。</p>
<p><code>MutationObserver</code>：監聽 DOM 變化偵測頁面內容更新。但 MutationObserver 觸發頻率高，需要 debounce 並搭配 URL 變化檢查，避免把 DOM 微調誤判為路由變換。</p>
<p>框架特定的 hook：如果 SDK 提供框架整合套件（React / Vue / Angular plugin），可以用框架的 router 事件（<code>useNavigate</code> hook、<code>router.afterEach</code> guard）直接取得路由變換資訊，比 monkey-patch History API 更可靠。</p>
<p>JS/TS 的平台限制理解後，其他平台各有各的挑戰 — <a href="/blog/devops/monitoring/05-platform-adaptation/flutter-platform/" data-link-title="Flutter 平台適配" data-link-desc="Isolate 安全、Platform channel 攔截、app lifecycle 事件 — Flutter SDK 的平台特殊考量">Flutter 平台適配</a>處理 isolate 和 platform channel 的問題。所有平台共同面對的 <a href="/blog/devops/monitoring/05-platform-adaptation/cross-platform-timestamp/" data-link-title="跨平台 timestamp 一致性" data-link-desc="時區、精度、clock drift — 不同平台產生的 timestamp 在 collector 端需要能正確比對和排序">timestamp 一致性</a>問題（時區、精度、clock drift）在獨立章節中展開。SDK 的跨平台公開 API 設計見<a href="/blog/devops/monitoring/03-sdk-design/public-api/" data-link-title="SDK 公開 API 設計" data-link-desc="init / event / error / metric / flush / close 六個方法構成 SDK 的完整生命週期 — 跨平台共用相同 API 介面">模組三 SDK 公開 API</a>。</p>
]]></content:encoded></item><item><title>Flutter 平台適配</title><link>https://tarrragon.github.io/blog/devops/monitoring/05-platform-adaptation/flutter-platform/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/monitoring/05-platform-adaptation/flutter-platform/</guid><description>&lt;p>Flutter 應用程式在 Dart VM 中執行，有自己的執行緒模型（Isolate）、原生平台橋接（Platform channel）和 app 生命週期管理。監控 SDK 在 Flutter 中需要處理的平台特殊問題集中在這三個面向。&lt;/p>
&lt;h2 id="isolate-安全">Isolate 安全&lt;/h2>
&lt;p>Dart 的 Isolate 是獨立的記憶體空間，Isolate 之間不共享記憶體，只能透過 message passing 溝通。SDK 的記憶體 buffer 存在於 main isolate 中，其他 isolate 產生的事件需要透過 port 傳送到 main isolate 才能進入 buffer。&lt;/p>
&lt;p>SDK 端的適配：&lt;/p>
&lt;p>提供 &lt;code>Monitor.eventFromIsolate(SendPort port)&lt;/code> 方法，在子 isolate 中透過 port 把事件送回 main isolate。或者提供 isolate-aware 的 &lt;code>Monitor.init()&lt;/code> 變體，在子 isolate 中初始化一個輕量的 event forwarder。&lt;/p>
&lt;p>如果 SDK 使用 compute 或 Isolate.spawn 做背景任務（例如壓縮 buffer），需要透過 port 把結果送回 main isolate — 背景 isolate 無法直接存取 main isolate 的 HTTP client 或 buffer。&lt;/p>
&lt;h2 id="platform-channel-攔截">Platform channel 攔截&lt;/h2>
&lt;p>Flutter 透過 Platform channel 呼叫原生平台功能（iOS 的 Swift/ObjC、Android 的 Kotlin/Java）。Platform channel 的呼叫可能失敗（原生端未實作、參數格式錯誤、原生端拋出例外），這些錯誤在 Dart 端表現為 &lt;code>PlatformException&lt;/code>。&lt;/p>
&lt;p>SDK 可以攔截 Platform channel 的呼叫記錄每次呼叫的方法名稱、參數、結果和耗時。攔截方式是替換 &lt;code>ServicesBinding.defaultBinaryMessenger&lt;/code> 的處理器，在轉發前後記錄事件。&lt;/p>
&lt;p>攔截的價值是：Platform channel 的錯誤通常難以 debug（stack trace 跨越 Dart 和原生兩層），監控記錄提供「呼叫了哪個 channel method、傳了什麼參數、在哪一層失敗」的完整 context。&lt;/p>
&lt;p>注意：攔截 Platform channel 會增加每次呼叫的延遲（記錄事件的開銷）。對高頻的 Platform channel 呼叫（例如每幀都呼叫的渲染相關 channel），攔截可能影響效能。SDK 應該提供 channel 過濾機制 — 只攔截特定 channel 或只在 debug mode 攔截。&lt;/p>
&lt;h2 id="app-lifecycle-事件">App lifecycle 事件&lt;/h2>
&lt;p>Flutter 的 &lt;code>WidgetsBindingObserver&lt;/code> 提供 app 生命週期回呼：&lt;/p>
&lt;ul>
&lt;li>&lt;code>didChangeAppLifecycleState(AppLifecycleState state)&lt;/code> — app 在 resumed（前景）、inactive（部分可見）、paused（背景）、detached（即將關閉）之間切換。&lt;/li>
&lt;/ul>
&lt;p>SDK 在 init 時註冊 observer，記錄每次狀態轉換為 lifecycle 事件。&lt;/p>
&lt;p>lifecycle 事件在 flush 策略中有特殊意義：&lt;/p>
&lt;p>&lt;strong>paused（進入背景）&lt;/strong>：觸發 flush — 把 buffer 中的事件送出，因為 app 在背景可能被系統殺掉，buffer 中的事件會遺失。iOS 在 app 進入背景後約 5 秒 suspend，flush 必須在這個時間窗口內完成。&lt;/p>
&lt;p>&lt;strong>resumed（回到前景）&lt;/strong>：檢查上次 flush 是否成功。如果 paused 時的 flush 失敗（網路超時），在 resumed 時重試。&lt;/p>
&lt;p>&lt;strong>detached（即將關閉）&lt;/strong>：呼叫 &lt;code>Monitor.close()&lt;/code> 做最後一次 flush 和資源釋放。detached 的時間窗口更短，close flush 可能被截斷。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>Python 平台的適配 → &lt;a href="https://tarrragon.github.io/blog/devops/monitoring/05-platform-adaptation/python-platform/" data-link-title="Python 平台適配" data-link-desc="GIL 與 threading、atexit 可靠性、subprocess 監控 — Python SDK 的平台特殊考量">Python 平台適配&lt;/a>&lt;/li>
&lt;li>跨平台 timestamp 一致性 → &lt;a href="https://tarrragon.github.io/blog/devops/monitoring/05-platform-adaptation/cross-platform-timestamp/" data-link-title="跨平台 timestamp 一致性" data-link-desc="時區、精度、clock drift — 不同平台產生的 timestamp 在 collector 端需要能正確比對和排序">跨平台 timestamp 一致性&lt;/a>&lt;/li>
&lt;li>自動攔截機制 → &lt;a href="https://tarrragon.github.io/blog/devops/monitoring/03-sdk-design/auto-intercept/" data-link-title="自動攔截機制" data-link-desc="JS window.onerror / Flutter FlutterError.onError / Python sys.excepthook — 各平台攔截未捕獲例外的機制和限制">模組三 自動攔截&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Flutter 應用程式在 Dart VM 中執行，有自己的執行緒模型（Isolate）、原生平台橋接（Platform channel）和 app 生命週期管理。監控 SDK 在 Flutter 中需要處理的平台特殊問題集中在這三個面向。</p>
<h2 id="isolate-安全">Isolate 安全</h2>
<p>Dart 的 Isolate 是獨立的記憶體空間，Isolate 之間不共享記憶體，只能透過 message passing 溝通。SDK 的記憶體 buffer 存在於 main isolate 中，其他 isolate 產生的事件需要透過 port 傳送到 main isolate 才能進入 buffer。</p>
<p>SDK 端的適配：</p>
<p>提供 <code>Monitor.eventFromIsolate(SendPort port)</code> 方法，在子 isolate 中透過 port 把事件送回 main isolate。或者提供 isolate-aware 的 <code>Monitor.init()</code> 變體，在子 isolate 中初始化一個輕量的 event forwarder。</p>
<p>如果 SDK 使用 compute 或 Isolate.spawn 做背景任務（例如壓縮 buffer），需要透過 port 把結果送回 main isolate — 背景 isolate 無法直接存取 main isolate 的 HTTP client 或 buffer。</p>
<h2 id="platform-channel-攔截">Platform channel 攔截</h2>
<p>Flutter 透過 Platform channel 呼叫原生平台功能（iOS 的 Swift/ObjC、Android 的 Kotlin/Java）。Platform channel 的呼叫可能失敗（原生端未實作、參數格式錯誤、原生端拋出例外），這些錯誤在 Dart 端表現為 <code>PlatformException</code>。</p>
<p>SDK 可以攔截 Platform channel 的呼叫記錄每次呼叫的方法名稱、參數、結果和耗時。攔截方式是替換 <code>ServicesBinding.defaultBinaryMessenger</code> 的處理器，在轉發前後記錄事件。</p>
<p>攔截的價值是：Platform channel 的錯誤通常難以 debug（stack trace 跨越 Dart 和原生兩層），監控記錄提供「呼叫了哪個 channel method、傳了什麼參數、在哪一層失敗」的完整 context。</p>
<p>注意：攔截 Platform channel 會增加每次呼叫的延遲（記錄事件的開銷）。對高頻的 Platform channel 呼叫（例如每幀都呼叫的渲染相關 channel），攔截可能影響效能。SDK 應該提供 channel 過濾機制 — 只攔截特定 channel 或只在 debug mode 攔截。</p>
<h2 id="app-lifecycle-事件">App lifecycle 事件</h2>
<p>Flutter 的 <code>WidgetsBindingObserver</code> 提供 app 生命週期回呼：</p>
<ul>
<li><code>didChangeAppLifecycleState(AppLifecycleState state)</code> — app 在 resumed（前景）、inactive（部分可見）、paused（背景）、detached（即將關閉）之間切換。</li>
</ul>
<p>SDK 在 init 時註冊 observer，記錄每次狀態轉換為 lifecycle 事件。</p>
<p>lifecycle 事件在 flush 策略中有特殊意義：</p>
<p><strong>paused（進入背景）</strong>：觸發 flush — 把 buffer 中的事件送出，因為 app 在背景可能被系統殺掉，buffer 中的事件會遺失。iOS 在 app 進入背景後約 5 秒 suspend，flush 必須在這個時間窗口內完成。</p>
<p><strong>resumed（回到前景）</strong>：檢查上次 flush 是否成功。如果 paused 時的 flush 失敗（網路超時），在 resumed 時重試。</p>
<p><strong>detached（即將關閉）</strong>：呼叫 <code>Monitor.close()</code> 做最後一次 flush 和資源釋放。detached 的時間窗口更短，close flush 可能被截斷。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Python 平台的適配 → <a href="/blog/devops/monitoring/05-platform-adaptation/python-platform/" data-link-title="Python 平台適配" data-link-desc="GIL 與 threading、atexit 可靠性、subprocess 監控 — Python SDK 的平台特殊考量">Python 平台適配</a></li>
<li>跨平台 timestamp 一致性 → <a href="/blog/devops/monitoring/05-platform-adaptation/cross-platform-timestamp/" data-link-title="跨平台 timestamp 一致性" data-link-desc="時區、精度、clock drift — 不同平台產生的 timestamp 在 collector 端需要能正確比對和排序">跨平台 timestamp 一致性</a></li>
<li>自動攔截機制 → <a href="/blog/devops/monitoring/03-sdk-design/auto-intercept/" data-link-title="自動攔截機制" data-link-desc="JS window.onerror / Flutter FlutterError.onError / Python sys.excepthook — 各平台攔截未捕獲例外的機制和限制">模組三 自動攔截</a></li>
</ul>
]]></content:encoded></item><item><title>Python 平台適配</title><link>https://tarrragon.github.io/blog/devops/monitoring/05-platform-adaptation/python-platform/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/monitoring/05-platform-adaptation/python-platform/</guid><description>&lt;p>Python 的執行模型（GIL 限制並行、atexit 不保證執行、subprocess 獨立 process）讓監控 SDK 在 Python 環境中需要特別處理 flush 的執行方式、程序退出時的事件保存和子程序的監控。&lt;/p>
&lt;h2 id="gil-與-threading">GIL 與 threading&lt;/h2>
&lt;p>Python 的 Global Interpreter Lock（GIL）讓同一時間只有一個 thread 執行 Python bytecode。SDK 的 flush 操作（HTTP POST 到 collector）如果在主 thread 執行，會阻塞主程式的其他工作。&lt;/p>
&lt;p>SDK 端的適配：&lt;/p>
&lt;p>在 daemon thread 中執行 flush。Daemon thread 在主 thread 結束時自動終止，不需要手動 join。SDK 的 flush 計時器在 daemon thread 中運行，buffer 的存取用 threading.Lock 保護。&lt;/p>
&lt;p>GIL 對 SDK 的影響比想像的小：HTTP 請求是 I/O bound 操作，CPython 在等待 I/O 時釋放 GIL。所以 flush 的 HTTP POST 在 daemon thread 中執行時，主 thread 可以繼續工作。GIL 只在 CPU-bound 的操作上造成瓶頸 — SDK 的 buffer 操作和事件序列化是 CPU-bound 但耗時極短（微秒級），影響可忽略。&lt;/p>
&lt;h3 id="asyncio-環境">asyncio 環境&lt;/h3>
&lt;p>Python 的 asyncio 程式（FastAPI、aiohttp）使用事件迴圈而非 threading。SDK 在 asyncio 環境中應該用 &lt;code>asyncio.create_task&lt;/code> 而非 threading 執行 flush，避免在事件迴圈中阻塞。&lt;/p>
&lt;p>SDK 可以在 init 時自動偵測是否在 asyncio 環境中（檢查 &lt;code>asyncio.get_running_loop()&lt;/code> 是否存在），自動切換 flush 的執行方式。&lt;/p>
&lt;h2 id="atexit-可靠性">atexit 可靠性&lt;/h2>
&lt;p>&lt;code>atexit.register&lt;/code> 在 Python 程序正常退出時執行註冊的清理函式。SDK 在 init 時註冊 atexit handler 做最後一次 flush。&lt;/p>
&lt;p>atexit 不執行的場景：&lt;/p>
&lt;ul>
&lt;li>&lt;code>os._exit()&lt;/code> 直接終止 process，跳過所有清理&lt;/li>
&lt;li>SIGKILL（&lt;code>kill -9&lt;/code>）強制終止，作業系統直接回收 process&lt;/li>
&lt;li>未處理的 fatal signal（SIGSEGV、SIGABRT）導致 crash&lt;/li>
&lt;/ul>
&lt;p>對於 SIGTERM 和 SIGINT，Python 預設會執行 atexit handler（前提是 signal handler 沒有被覆蓋）。SDK 可以額外註冊 &lt;code>signal.signal(signal.SIGTERM, handler)&lt;/code> 確保在收到 SIGTERM 時觸發 flush。&lt;/p>
&lt;p>實務影響：&lt;code>os._exit()&lt;/code> 和 SIGKILL 導致的事件遺失無法避免。使用本地 persistence（&lt;a href="https://tarrragon.github.io/blog/devops/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">離線 buffer&lt;/a>）可以降低影響 — 事件在寫入本地檔案後，即使 process 被強制終止，下次啟動時仍可補發。&lt;/p>
&lt;h2 id="短生命週期腳本">短生命週期腳本&lt;/h2>
&lt;p>SDK 的預設設計假設長期運行的 app — flush interval 定期觸發、daemon thread 持續運行、atexit 是最後防線。但 Python SDK 的一個重要場景是短命腳本（CI/CD hook、pre-commit hook、CLI 工具的子命令），生命週期可能 &amp;lt; 1 秒。這個場景下 SDK 的行為和長期 app 完全不同。&lt;/p>
&lt;h3 id="什麼會壞">什麼會壞&lt;/h3>
&lt;p>&lt;strong>flush interval 來不及觸發&lt;/strong>。預設 30 秒的 flush interval，但腳本在 200ms 內結束。計時器還沒觸發，buffer 中的事件從未送出。&lt;/p></description><content:encoded><![CDATA[<p>Python 的執行模型（GIL 限制並行、atexit 不保證執行、subprocess 獨立 process）讓監控 SDK 在 Python 環境中需要特別處理 flush 的執行方式、程序退出時的事件保存和子程序的監控。</p>
<h2 id="gil-與-threading">GIL 與 threading</h2>
<p>Python 的 Global Interpreter Lock（GIL）讓同一時間只有一個 thread 執行 Python bytecode。SDK 的 flush 操作（HTTP POST 到 collector）如果在主 thread 執行，會阻塞主程式的其他工作。</p>
<p>SDK 端的適配：</p>
<p>在 daemon thread 中執行 flush。Daemon thread 在主 thread 結束時自動終止，不需要手動 join。SDK 的 flush 計時器在 daemon thread 中運行，buffer 的存取用 threading.Lock 保護。</p>
<p>GIL 對 SDK 的影響比想像的小：HTTP 請求是 I/O bound 操作，CPython 在等待 I/O 時釋放 GIL。所以 flush 的 HTTP POST 在 daemon thread 中執行時，主 thread 可以繼續工作。GIL 只在 CPU-bound 的操作上造成瓶頸 — SDK 的 buffer 操作和事件序列化是 CPU-bound 但耗時極短（微秒級），影響可忽略。</p>
<h3 id="asyncio-環境">asyncio 環境</h3>
<p>Python 的 asyncio 程式（FastAPI、aiohttp）使用事件迴圈而非 threading。SDK 在 asyncio 環境中應該用 <code>asyncio.create_task</code> 而非 threading 執行 flush，避免在事件迴圈中阻塞。</p>
<p>SDK 可以在 init 時自動偵測是否在 asyncio 環境中（檢查 <code>asyncio.get_running_loop()</code> 是否存在），自動切換 flush 的執行方式。</p>
<h2 id="atexit-可靠性">atexit 可靠性</h2>
<p><code>atexit.register</code> 在 Python 程序正常退出時執行註冊的清理函式。SDK 在 init 時註冊 atexit handler 做最後一次 flush。</p>
<p>atexit 不執行的場景：</p>
<ul>
<li><code>os._exit()</code> 直接終止 process，跳過所有清理</li>
<li>SIGKILL（<code>kill -9</code>）強制終止，作業系統直接回收 process</li>
<li>未處理的 fatal signal（SIGSEGV、SIGABRT）導致 crash</li>
</ul>
<p>對於 SIGTERM 和 SIGINT，Python 預設會執行 atexit handler（前提是 signal handler 沒有被覆蓋）。SDK 可以額外註冊 <code>signal.signal(signal.SIGTERM, handler)</code> 確保在收到 SIGTERM 時觸發 flush。</p>
<p>實務影響：<code>os._exit()</code> 和 SIGKILL 導致的事件遺失無法避免。使用本地 persistence（<a href="/blog/devops/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">離線 buffer</a>）可以降低影響 — 事件在寫入本地檔案後，即使 process 被強制終止，下次啟動時仍可補發。</p>
<h2 id="短生命週期腳本">短生命週期腳本</h2>
<p>SDK 的預設設計假設長期運行的 app — flush interval 定期觸發、daemon thread 持續運行、atexit 是最後防線。但 Python SDK 的一個重要場景是短命腳本（CI/CD hook、pre-commit hook、CLI 工具的子命令），生命週期可能 &lt; 1 秒。這個場景下 SDK 的行為和長期 app 完全不同。</p>
<h3 id="什麼會壞">什麼會壞</h3>
<p><strong>flush interval 來不及觸發</strong>。預設 30 秒的 flush interval，但腳本在 200ms 內結束。計時器還沒觸發，buffer 中的事件從未送出。</p>
<p><strong>daemon thread 隨主 thread 結束</strong>。SDK 用 daemon thread 執行 flush 計時器。Python 的 daemon thread 在最後一個非 daemon thread 結束時被殺 — 不會等待 daemon thread 完成當前工作。如果 flush 正在進行中（HTTP POST 送到一半），daemon thread 被殺，HTTP 請求中斷，事件丟失。</p>
<p><strong>atexit 的執行順序不確定</strong>。atexit handler 在 daemon thread 被殺之後執行。如果 SDK 的 atexit handler 嘗試在 daemon thread 中 flush，會失敗（thread 已死）。atexit handler 必須在主 thread 中同步 flush。</p>
<h3 id="正確的短命腳本模式">正確的短命腳本模式</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">from</span> <span class="nn">monitor</span> <span class="kn">import</span> <span class="n">Monitor</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">Monitor</span><span class="o">.</span><span class="n">init</span><span class="p">(</span><span class="n">endpoint</span><span class="o">=</span><span class="s2">&#34;http://localhost:9090/v1/events&#34;</span><span class="p">,</span> <span class="n">app</span><span class="o">=</span><span class="s2">&#34;my-hook&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 做事...</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">Monitor</span><span class="o">.</span><span class="n">event</span><span class="p">(</span><span class="s2">&#34;hook.run&#34;</span><span class="p">,</span> <span class="p">{</span><span class="s2">&#34;hook&#34;</span><span class="p">:</span> <span class="s2">&#34;branch-check&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"># 結束前必須呼叫 close</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="n">Monitor</span><span class="o">.</span><span class="n">close</span><span class="p">()</span>  <span class="c1"># close 內同步 flush，不依賴 daemon thread</span></span></span></code></pre></div><p><code>close()</code> 是唯一可靠的 flush 時機。<code>close()</code> 的實作在短命腳本場景下必須：</p>
<ol>
<li><strong>同步執行 HTTP POST</strong>，不委託給 daemon thread — 主 thread 呼叫 <code>close()</code> 時直接在當前 thread 送出</li>
<li><strong>設 HTTP timeout</strong> — 短命腳本不能等太久，3 秒的 timeout 是合理的</li>
<li><strong>flush 失敗時靜默放棄</strong> — 短命腳本的主要職責不是監控，SDK 失敗不應影響腳本的 exit code</li>
</ol>
<p><code>atexit</code> 仍然註冊，作為開發者忘記呼叫 <code>close()</code> 的備份。但 atexit 是 best-effort — 在 <code>os._exit()</code> 和 SIGKILL 下不執行。</p>
<h3 id="flush-interval-在短命腳本中的角色">flush interval 在短命腳本中的角色</h3>
<p>flush interval 對短命腳本無意義 — 腳本在第一次 interval 觸發前就結束了。SDK 可以偵測「init 到 close 的間隔 &lt; flush interval」的模式，在 debug log 中提示開發者考慮降低 interval 或直接依賴 <code>close()</code> flush。</p>
<p>但不建議把 flush interval 設為 0（停用）— 同一個 SDK 設定可能同時用於長期 app 和短命腳本，interval 對長期 app 仍然有用。</p>
<h2 id="subprocess-監控">Subprocess 監控</h2>
<p>Python 程式中的 <code>subprocess.Popen</code> 啟動的子程序是獨立的 process，不共享 SDK 的 buffer 和網路連線。子程序的錯誤和事件需要獨立的監控機制。</p>
<p>兩種方式：</p>
<p><strong>子程序獨立初始化 SDK</strong>：子程序的 Python 腳本自己呼叫 <code>Monitor.init()</code>，獨立送事件到 collector。適合子程序是長時間運行的 Python 程式。</p>
<p><strong>父程序代理</strong>：父程序讀取子程序的 stdout/stderr，從輸出中解析事件（子程序用約定格式印出事件），父程序的 SDK 代理送出。適合子程序是短命的腳本或非 Python 程式。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Go 平台的適配 → <a href="/blog/devops/monitoring/05-platform-adaptation/go-platform/" data-link-title="Go 平台適配" data-link-desc="Graceful shutdown、signal handling、HTTP server 自身監控 — Go SDK 和 collector 端共同面對的平台問題">Go 平台適配</a></li>
<li>跨平台 timestamp 一致性 → <a href="/blog/devops/monitoring/05-platform-adaptation/cross-platform-timestamp/" data-link-title="跨平台 timestamp 一致性" data-link-desc="時區、精度、clock drift — 不同平台產生的 timestamp 在 collector 端需要能正確比對和排序">跨平台 timestamp 一致性</a></li>
<li>離線 buffer 策略 → <a href="/blog/devops/monitoring/03-sdk-design/offline-buffer/" data-link-title="離線 buffer 與重試" data-link-desc="網路不可用時的事件保存策略 — FIFO 丟棄、本地 persistence、恢復後補發的取捨">模組三 離線 buffer 與重試</a></li>
</ul>
]]></content:encoded></item><item><title>Go 平台適配</title><link>https://tarrragon.github.io/blog/devops/monitoring/05-platform-adaptation/go-platform/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/monitoring/05-platform-adaptation/go-platform/</guid><description>&lt;p>Go 的 monitoring SDK 和其他平台 SDK 的定位不同。JS / Flutter / Python SDK 是 client-side 的事件上報工具，Go SDK 更常用在 server-side — 包括 collector 本身的自身監控。Go 的 goroutine 並行模型、signal handling 機制和 HTTP server 的 graceful shutdown 是 Go 環境中的三個核心適配問題。&lt;/p>
&lt;h2 id="graceful-shutdown">Graceful shutdown&lt;/h2>
&lt;p>Go 程式收到 SIGTERM 或 SIGINT 時需要在退出前完成清理：flush 剩餘的 buffer、關閉網路連線、寫入最後的 lifecycle 事件。&lt;/p>





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





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">monitorMiddleware&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">next&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Handler&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Handler&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">HandlerFunc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">monitor&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RecoverAndReport&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">next&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ServeHTTP&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="http-server-自身監控">HTTP server 自身監控&lt;/h2>
&lt;p>Go 常用來寫 collector 本身。Collector 需要監控自己的健康狀態 — 請求處理速率、錯誤率、goroutine 數量、記憶體使用量。&lt;/p></description><content:encoded><![CDATA[<p>Go 的 monitoring SDK 和其他平台 SDK 的定位不同。JS / Flutter / Python SDK 是 client-side 的事件上報工具，Go SDK 更常用在 server-side — 包括 collector 本身的自身監控。Go 的 goroutine 並行模型、signal handling 機制和 HTTP server 的 graceful shutdown 是 Go 環境中的三個核心適配問題。</p>
<h2 id="graceful-shutdown">Graceful shutdown</h2>
<p>Go 程式收到 SIGTERM 或 SIGINT 時需要在退出前完成清理：flush 剩餘的 buffer、關閉網路連線、寫入最後的 lifecycle 事件。</p>





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">monitorMiddleware</span><span class="p">(</span><span class="nx">next</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span><span class="p">)</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">http</span><span class="p">.</span><span class="nf">HandlerFunc</span><span class="p">(</span><span class="kd">func</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">defer</span> <span class="nx">monitor</span><span class="p">.</span><span class="nf">RecoverAndReport</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">next</span><span class="p">.</span><span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">r</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h2 id="http-server-自身監控">HTTP server 自身監控</h2>
<p>Go 常用來寫 collector 本身。Collector 需要監控自己的健康狀態 — 請求處理速率、錯誤率、goroutine 數量、記憶體使用量。</p>
<p>Collector 的自身監控和接收外部事件是兩個獨立的管線。自身監控的 metric 可以寫入獨立的 JSONL 檔案（和外部事件分開），或透過 Go 的 <code>expvar</code> / <code>runtime.ReadMemStats</code> 暴露為 HTTP endpoint。</p>
<p>自身監控的關鍵指標：</p>
<ul>
<li><code>collector.events.received</code>：每秒收到的事件數</li>
<li><code>collector.events.invalid</code>：schema 驗證失敗的事件數</li>
<li><code>collector.storage.write_duration_ms</code>：寫入 JSONL 的耗時</li>
<li><code>collector.goroutines</code>：goroutine 數量（洩漏偵測）</li>
<li><code>collector.memory.alloc_mb</code>：記憶體使用量</li>
</ul>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>跨平台 timestamp 一致性 → <a href="/blog/devops/monitoring/05-platform-adaptation/cross-platform-timestamp/" data-link-title="跨平台 timestamp 一致性" data-link-desc="時區、精度、clock drift — 不同平台產生的 timestamp 在 collector 端需要能正確比對和排序">跨平台 timestamp 一致性</a></li>
<li>Collector 的架構設計 → <a href="/blog/devops/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計</a></li>
<li>SDK 公開 API 的 Close 方法 → <a href="/blog/devops/monitoring/03-sdk-design/public-api/" data-link-title="SDK 公開 API 設計" data-link-desc="init / event / error / metric / flush / close 六個方法構成 SDK 的完整生命週期 — 跨平台共用相同 API 介面">模組三 SDK 公開 API</a></li>
</ul>
]]></content:encoded></item><item><title>跨平台 timestamp 一致性</title><link>https://tarrragon.github.io/blog/devops/monitoring/05-platform-adaptation/cross-platform-timestamp/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/monitoring/05-platform-adaptation/cross-platform-timestamp/</guid><description>&lt;p>跨平台的監控系統收到來自不同平台（JS / Flutter / Python / Go）的事件，每個平台的 timestamp 格式、精度和時鐘來源不同。Collector 需要對這些 timestamp 做排序、分組和時間範圍查詢，一致性問題會導致事件順序錯亂和分析結果偏差。&lt;/p>
&lt;h2 id="統一格式iso-8601--時區偏移">統一格式：ISO 8601 + 時區偏移&lt;/h2>
&lt;p>所有平台的 SDK 統一使用 ISO 8601 格式，包含毫秒精度和時區偏移：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">2026-06-19T14:30:00.123+08:00&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>避免使用 Unix timestamp（秒或毫秒）作為僅有的時間表示 — Unix timestamp 沒有時區資訊，如果 SDK 端和 collector 端在不同時區，需要額外的 metadata 才能正確轉換。&lt;/p>
&lt;p>避免使用「本地時間不帶時區」的格式（&lt;code>2026-06-19T14:30:00&lt;/code>）— 無法區分 UTC+8 的 14:30 和 UTC+0 的 14:30。&lt;/p>
&lt;h2 id="各平台的-timestamp-來源">各平台的 timestamp 來源&lt;/h2>
&lt;h3 id="javascript">JavaScript&lt;/h3>
&lt;p>&lt;code>Date.now()&lt;/code> 回傳毫秒精度的 Unix timestamp。&lt;code>new Date().toISOString()&lt;/code> 回傳 UTC 時間的 ISO 8601 字串。&lt;/p>
&lt;p>SDK 應該用 &lt;code>Intl.DateTimeFormat&lt;/code> 或手動計算時區偏移，產生帶本地時區的 ISO 8601 字串 — collector 端需要知道事件的本地時間，以便做使用者時區的分析。&lt;/p>
&lt;p>&lt;code>performance.now()&lt;/code> 提供微秒精度的高解析度時間，但起點是頁面載入時間，無法用來產生絕對 timestamp。用於計算 duration（兩個時間點的差值），不用於記錄事件時間。&lt;/p>
&lt;h3 id="flutter--dart">Flutter / Dart&lt;/h3>
&lt;p>&lt;code>DateTime.now()&lt;/code> 回傳本地時間的 DateTime 物件。&lt;code>DateTime.now().toUtc()&lt;/code> 轉成 UTC。&lt;code>DateTime.now().toIso8601String()&lt;/code> 產生 ISO 8601 字串，但不包含時區偏移（Dart 的 ISO 8601 格式不包含 offset）。&lt;/p>
&lt;p>SDK 需要手動附加時區偏移：&lt;code>DateTime.now().timeZoneOffset&lt;/code> 取得偏移量，手動格式化為 &lt;code>+08:00&lt;/code> 格式附加到 ISO 8601 字串後面。&lt;/p>
&lt;h3 id="python">Python&lt;/h3>
&lt;p>&lt;code>datetime.now(timezone.utc)&lt;/code> 取得 UTC 時間。&lt;code>datetime.now().astimezone()&lt;/code> 取得本地時間帶時區。&lt;code>.isoformat()&lt;/code> 產生帶時區偏移的 ISO 8601 字串。&lt;/p>
&lt;p>Python 3.2+ 的 &lt;code>datetime&lt;/code> 原生支援 timezone-aware 的 ISO 8601 輸出，是各平台中最完整的。&lt;/p>
&lt;h3 id="go">Go&lt;/h3>
&lt;p>&lt;code>time.Now()&lt;/code> 回傳帶時區的 Time 值。&lt;code>time.Now().Format(time.RFC3339Milli)&lt;/code> 產生帶毫秒和時區偏移的字串。&lt;/p>
&lt;p>Go 的 &lt;code>time.RFC3339Nano&lt;/code> 提供奈秒精度，但監控事件不需要這個精度 — 毫秒足夠。&lt;/p>
&lt;h2 id="clock-drift">Clock drift&lt;/h2>
&lt;p>不同裝置的系統時鐘可能有偏差（clock drift）。使用者手機的時鐘比 collector server 快 5 分鐘，SDK 產生的 timestamp 會比 collector 收到時間早 5 分鐘。&lt;/p>
&lt;p>Clock drift 的影響：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>排序錯亂&lt;/strong>：裝置 A（時鐘快）和裝置 B（時鐘慢）的事件混合排序時，時間順序可能和真實發生順序不一致&lt;/li>
&lt;li>&lt;strong>告警延遲計算錯誤&lt;/strong>：collector 用「事件 timestamp 到收到時間的差值」計算延遲，clock drift 讓延遲值不準確&lt;/li>
&lt;/ul>
&lt;p>處理策略：&lt;/p>
&lt;p>&lt;strong>Collector 記錄 receive_timestamp&lt;/strong>：每筆事件除了 SDK 端的 timestamp，collector 在收到時附加 &lt;code>receive_timestamp&lt;/code>。兩者的差值用於估算 clock drift 和網路延遲。&lt;/p>
&lt;p>&lt;strong>容忍而非修正&lt;/strong>：在數秒到數分鐘級的 drift 範圍內，容忍 drift 帶來的排序不精確。跨裝置的事件排序本身就不需要毫秒精度 — 分析的粒度通常是秒或分鐘。&lt;/p>
&lt;p>&lt;strong>異常值偵測&lt;/strong>：timestamp 比 receive_timestamp 早超過 1 小時，或晚超過 5 分鐘，標記為可疑的 clock drift — 可能是使用者手動調整了系統時鐘。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>JS 平台適配 → &lt;a href="https://tarrragon.github.io/blog/devops/monitoring/05-platform-adaptation/js-ts-platform/" data-link-title="JS/TS 平台適配" data-link-desc="CORS 限制、Service Worker 攔截、SPA 路由變換偵測 — 瀏覽器環境中 SDK 需要處理的平台特殊問題">JS/TS 平台適配&lt;/a>&lt;/li>
&lt;li>Flutter 平台適配 → &lt;a href="https://tarrragon.github.io/blog/devops/monitoring/05-platform-adaptation/flutter-platform/" data-link-title="Flutter 平台適配" data-link-desc="Isolate 安全、Platform channel 攔截、app lifecycle 事件 — Flutter SDK 的平台特殊考量">Flutter 平台適配&lt;/a>&lt;/li>
&lt;li>Log schema 中的 timestamp 欄位 → &lt;a href="https://tarrragon.github.io/blog/devops/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">模組二 event.schema.json 欄位解說&lt;/a>&lt;/li>
&lt;li>各平台的 error 攔截差異影響 test 設計 → &lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/" data-link-title="模組五：測試設計判斷" data-link-desc="Mock 邊界判斷、assertion 設計、test data 代表性、flaky test 診斷">testing 模組五 測試設計判斷&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>跨平台的監控系統收到來自不同平台（JS / Flutter / Python / Go）的事件，每個平台的 timestamp 格式、精度和時鐘來源不同。Collector 需要對這些 timestamp 做排序、分組和時間範圍查詢，一致性問題會導致事件順序錯亂和分析結果偏差。</p>
<h2 id="統一格式iso-8601--時區偏移">統一格式：ISO 8601 + 時區偏移</h2>
<p>所有平台的 SDK 統一使用 ISO 8601 格式，包含毫秒精度和時區偏移：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">2026-06-19T14:30:00.123+08:00</span></span></code></pre></div><p>避免使用 Unix timestamp（秒或毫秒）作為僅有的時間表示 — Unix timestamp 沒有時區資訊，如果 SDK 端和 collector 端在不同時區，需要額外的 metadata 才能正確轉換。</p>
<p>避免使用「本地時間不帶時區」的格式（<code>2026-06-19T14:30:00</code>）— 無法區分 UTC+8 的 14:30 和 UTC+0 的 14:30。</p>
<h2 id="各平台的-timestamp-來源">各平台的 timestamp 來源</h2>
<h3 id="javascript">JavaScript</h3>
<p><code>Date.now()</code> 回傳毫秒精度的 Unix timestamp。<code>new Date().toISOString()</code> 回傳 UTC 時間的 ISO 8601 字串。</p>
<p>SDK 應該用 <code>Intl.DateTimeFormat</code> 或手動計算時區偏移，產生帶本地時區的 ISO 8601 字串 — collector 端需要知道事件的本地時間，以便做使用者時區的分析。</p>
<p><code>performance.now()</code> 提供微秒精度的高解析度時間，但起點是頁面載入時間，無法用來產生絕對 timestamp。用於計算 duration（兩個時間點的差值），不用於記錄事件時間。</p>
<h3 id="flutter--dart">Flutter / Dart</h3>
<p><code>DateTime.now()</code> 回傳本地時間的 DateTime 物件。<code>DateTime.now().toUtc()</code> 轉成 UTC。<code>DateTime.now().toIso8601String()</code> 產生 ISO 8601 字串，但不包含時區偏移（Dart 的 ISO 8601 格式不包含 offset）。</p>
<p>SDK 需要手動附加時區偏移：<code>DateTime.now().timeZoneOffset</code> 取得偏移量，手動格式化為 <code>+08:00</code> 格式附加到 ISO 8601 字串後面。</p>
<h3 id="python">Python</h3>
<p><code>datetime.now(timezone.utc)</code> 取得 UTC 時間。<code>datetime.now().astimezone()</code> 取得本地時間帶時區。<code>.isoformat()</code> 產生帶時區偏移的 ISO 8601 字串。</p>
<p>Python 3.2+ 的 <code>datetime</code> 原生支援 timezone-aware 的 ISO 8601 輸出，是各平台中最完整的。</p>
<h3 id="go">Go</h3>
<p><code>time.Now()</code> 回傳帶時區的 Time 值。<code>time.Now().Format(time.RFC3339Milli)</code> 產生帶毫秒和時區偏移的字串。</p>
<p>Go 的 <code>time.RFC3339Nano</code> 提供奈秒精度，但監控事件不需要這個精度 — 毫秒足夠。</p>
<h2 id="clock-drift">Clock drift</h2>
<p>不同裝置的系統時鐘可能有偏差（clock drift）。使用者手機的時鐘比 collector server 快 5 分鐘，SDK 產生的 timestamp 會比 collector 收到時間早 5 分鐘。</p>
<p>Clock drift 的影響：</p>
<ul>
<li><strong>排序錯亂</strong>：裝置 A（時鐘快）和裝置 B（時鐘慢）的事件混合排序時，時間順序可能和真實發生順序不一致</li>
<li><strong>告警延遲計算錯誤</strong>：collector 用「事件 timestamp 到收到時間的差值」計算延遲，clock drift 讓延遲值不準確</li>
</ul>
<p>處理策略：</p>
<p><strong>Collector 記錄 receive_timestamp</strong>：每筆事件除了 SDK 端的 timestamp，collector 在收到時附加 <code>receive_timestamp</code>。兩者的差值用於估算 clock drift 和網路延遲。</p>
<p><strong>容忍而非修正</strong>：在數秒到數分鐘級的 drift 範圍內，容忍 drift 帶來的排序不精確。跨裝置的事件排序本身就不需要毫秒精度 — 分析的粒度通常是秒或分鐘。</p>
<p><strong>異常值偵測</strong>：timestamp 比 receive_timestamp 早超過 1 小時，或晚超過 5 分鐘，標記為可疑的 clock drift — 可能是使用者手動調整了系統時鐘。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>JS 平台適配 → <a href="/blog/devops/monitoring/05-platform-adaptation/js-ts-platform/" data-link-title="JS/TS 平台適配" data-link-desc="CORS 限制、Service Worker 攔截、SPA 路由變換偵測 — 瀏覽器環境中 SDK 需要處理的平台特殊問題">JS/TS 平台適配</a></li>
<li>Flutter 平台適配 → <a href="/blog/devops/monitoring/05-platform-adaptation/flutter-platform/" data-link-title="Flutter 平台適配" data-link-desc="Isolate 安全、Platform channel 攔截、app lifecycle 事件 — Flutter SDK 的平台特殊考量">Flutter 平台適配</a></li>
<li>Log schema 中的 timestamp 欄位 → <a href="/blog/devops/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">模組二 event.schema.json 欄位解說</a></li>
<li>各平台的 error 攔截差異影響 test 設計 → <a href="/blog/testing/05-test-design-judgment/" data-link-title="模組五：測試設計判斷" data-link-desc="Mock 邊界判斷、assertion 設計、test data 代表性、flaky test 診斷">testing 模組五 測試設計判斷</a></li>
</ul>
]]></content:encoded></item></channel></rss>