假故障與靜默失效的診斷
這一章處理的故障有一個共同結構:症狀出現的位置不是問題所在的位置。錯誤訊息指向的那一行是無辜的、程式碼改對了卻沒有生效、公式完全合法卻算出錯誤的數字。三者的診斷路徑不同,但都要求先放下「訊息說哪裡壞就修哪裡」這個預設。
這套統計的執行環境被切成四段——頁面上的 JavaScript、Apps Script 的部署層、接收端程式碼、Google 試算表——而每一段的狀態各自獨立。任何一段沒跟上都不會產生跨段的錯誤訊號,這是免伺服器架構特別容易產生假故障的結構原因。
錯誤訊息指向的位置不是問題的位置
Google 服務類別的查詢方法在找不到目標時回傳 null,而不是拋出例外。錯誤因此延後到使用回傳值的那一刻才發生:
1function inspectHeaders() {
2 var sheet = SpreadsheetApp.getActive().getSheetByName('log');
3 var lastCol = sheet.getLastColumn(); // TypeError: Cannot read properties of null
4}訊息指向 getLastColumn,而真正的問題在上一行——這個試算表裡沒有名為 log 的工作表。判讀規則是:訊息形如「無法讀取 null 的某屬性」時,問題在產生那個 null 的地方,不在使用它的地方。
診斷方式是把假設換成觀測:先列出實際存在的工作表,再對照程式碼裡寫的名稱。
1function listSheets() {
2 SpreadsheetApp.getActive().getSheets().forEach(function (sheet) {
3 var lastCol = sheet.getLastColumn();
4 Logger.log('工作表「%s」 欄數=%s 列數=%s', sheet.getName(), lastCol, sheet.getLastRow());
5 });
6}這段程式碼的價值在於它不帶任何關於名稱的假設。同樣的模式適用於所有「用名稱或 ID 取得物件」的 API——先列出實際存在的東西,再對照程式碼裡寫的。
有一個延伸判讀值得記住:getActive() 回傳的是這個 Apps Script 專案所綁定的試算表。專案綁在 A 表、而資料寫進 B 表(透過 openById)是完全合法的配置,這時 getActive() 拿到的是 A 表。工作表名稱與欄位都對不上時,要先確認取得試算表的方式,再懷疑工作表名稱。
程式改了但端點沒生效
Apps Script 把「編輯器裡的程式碼」與「端點服務的版本」設計成兩個獨立狀態。手動執行函式跑的是前者,HTTP 請求打到的是後者——這代表「我執行某個函式成功了」與「端點跑的是新版」是兩件事,前者成立不推出後者。
這個設計本身是合理的版本控制:可以邊改程式碼邊讓線上端點維持穩定,改完再一次切版。代價是「改了沒生效」成為最常見的假故障。
判讀的訣竅是問一個問題:這次觸發是自己按的,還是外面打進來的。自己按的走編輯器最新碼,外面打進來的走部署版本。症狀是「手動測試都對、實際收到的資料卻是舊格式」時,答案幾乎必定是部署版本沒更新。
更新的操作是「管理部署作業 → 編輯 → 版本選新版本 → 部署」,這會保持網址不變(web app 部署的更新語意)。用「新增部署作業」會產生新網址,前端設定的端點就對不上了——這一點與模組一的部署模型講的是同一件事,只是從故障診斷的方向再看一次。
驗證用執行項目頁面:它列出每一次 doPost 的執行時間、耗時與狀態。這個頁面能回答一個關鍵問題——請求到底有沒有打到端點:
- 有執行紀錄但狀態失敗:請求送達了,接收端出錯
- 完全沒有對應時間的執行紀錄:請求根本沒送達,問題在瀏覽器端或網路
這個分岔把排查範圍砍半,而且它的證據來自平台本身,不依賴任何推測。實際運用的例子是判斷「某些記錄只有離開事件、沒有進入事件」的成因:執行紀錄裡完全找不到進入事件對應時間的執行,因此問題落在接收端之前——請求根本沒送出來。
這一步能確定的就到這裡。再往下的「為什麼沒送出來」屬於假說,執行紀錄支撐不了它:可能是那個瀏覽器環境攔截了頁面載入期間的請求,也可能是接收端當時併發撞頂而請求被拒(那會留下失敗紀錄,可以排除),或是網路瞬斷。把觀測與解釋分開記錄,之後拿到更多樣本時才知道哪一個假說被推翻了。
靜默失效:合法執行、輸出合法、數字是錯的
第三類故障持續執行、持續產出看起來合理的數字,而數字是錯的——前兩類至少會停下來。
試算表的分類公式是典型場所,而它最難察覺的形態是「修正一個問題時引入下一個」。事件模型那一章示範過第一次:單事件模型下的計數條件在雙事件模型下永遠不成立,那個分類的計數從此恆為零。
修法是把事件型別加進條件,而修正版的開頭多了一句「非進入事件的列一律留白」——避免同一次瀏覽被計數兩次。第二次失效就出在那一句。 它預設每次瀏覽都有進入事件,而真實資料裡存在只有離開事件的記錄(成因見上一節)。這些列被開頭那個條件整批留白,於是一整類資料在統計裡完全不存在——這不是算錯,是那一類從報表上消失了。
兩次的共同形態相同:公式沒有語法錯誤、沒有回傳錯誤值,只有分佈變了。
零是這裡最危險的輸出,因為它與「沒有這種流量」完全不可區分。 要察覺算錯,得先有一個獨立來源知道這裡本來應該有多少——而分析公式存在的理由,正是因為沒有那個獨立來源。
三個作法讓這類失效可被發現:
用總量守恆檢查涵蓋。 分類後各類數量的總和與原始列數的差額應該為零。差額不為零代表有資料形狀沒被任何條件接住,而這個檢查不要求預先知道漏了什麼——這是它比逐條檢視有效的地方。
讓未涵蓋的資料顯示成標籤而非留白。 分類邏輯的最後放一個 catch-all 分支,把沒被接住的列標成「未分類」。留白與「不適用」在視覺上不可區分,而一個會被看見的標籤才有機會促使人去查。
資料模型變更時把公式的重算納入同一次變更。 分析邏輯是資料模型的下游依賴,與欄位定義、寫入程式碼屬於同一次變更的範圍——延後處理的代價與重算的方法見事件模型那一節的示範。
抽象層的完整推導見 #250 資料多出一種形狀時,既有分析邏輯靜默換語意。
診斷順序
三類故障的排查有一個共通的推進方式:沿著資料的路徑,逐段確認實際狀態,而不是逐段確認程式碼寫了什麼。
| 位置 | 觀測方式 | 正常代表什麼 | 異常時往哪走 |
|---|---|---|---|
| 瀏覽器 | 開發者工具的網路面板,找送往端點的請求 | 事件有產生、也送出去了 | 查網域判斷與退出標記(見下方說明) |
| 傳輸途中 | 網路面板看該請求的狀態碼,或關掉擴充再試 | 請求離開瀏覽器、也沒被攔下 | 被攔截或逾時,換一個瀏覽器環境重試以確認 |
| 平台部署層 | 管理部署作業,比對網址與版本 | 端點跑的是預期的那份程式碼 | 用同一個部署更新版本,不新增部署 |
| 接收端執行 | 執行項目頁面,看時間與狀態 | 請求送達了、程式跑完了 | 有失敗紀錄查例外,無紀錄代表沒抵達、回上一列 |
| 試算表 | 直接看最新一列的欄位 | 資料寫進去了、位置正確 | 欄位錯位查 appendRow 的順序與表頭 |
| 彙總排程 | 觸發器的執行紀錄、日報最新一列的日期 | 定時彙總跑過、輸出有更新 | 查欄數與事件型別這兩個常數是否跟上資料模型 |
| 分析公式 | 進入事件列的總量守恆與未分類計數 | 統計涵蓋了所有實際存在的形狀 | 差額不為零時找沒被任何條件接住的資料形狀 |
三段的觀測值需要補充。瀏覽器那一段看的是請求有沒有發出,而它沒發出通常有兩個良性成因:beacon 前端有一道網域判斷(只在正式站送、本機預覽不送),以及讀者可能設過退出標記——兩者都會讓程式在送出前就返回,看起來與故障相同。
傳輸途中那一段是「發出了但沒抵達」的落點。接收端是一個外部網域,廣告與隱私擴充功能的通用規則可能直接擋下這個請求;瀏覽器的網路面板會顯示它被封鎖而非失敗。這一段存在的理由是前後兩段都答不了它:瀏覽器說「我送了」、執行紀錄說「我沒收到」,兩邊都沒說謊。
彙總排程那一段容易被漏掉,因為它不在 beacon 的即時路徑上。定時彙總讀的是同一張表,而它有自己的一組常數(讀幾欄、事件型別在第幾欄),資料模型變更時它們與分析公式同樣需要重算。
試算表那一段看的是最新一列的時間戳與欄位對位。欄位錯位與資料沒寫進去是兩種不同的故障——前者每一格都有值、看起來完全正常,成因是寫入端的欄位順序與表頭不同步,而它不會產生任何錯誤。
每一段都有一個「不看程式碼就能得到的觀測值」,這是這張表的設計重點。讀程式碼只能知道它宣稱會做什麼,而這幾類故障的成因全都是宣稱與實際之間的落差——落差只有實際觀測才看得見。
下一步
收集鏈確認健康之後,資料判讀的環節分別是:識別欄位見訪客識別與 opt-out、行為欄位見事件模型與停留時間、判定規則見辨識自動化流量、而這些欄位加總起來涵蓋了誰見這份統計的分母是什麼。配額耗盡導致的漏記是另一類成因,見模組五的配額碰撞與防濫用。
#automation #apps-script #debugging #data-quality #deployment