iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0

1. 前言:日報寫好了卻沒有人去看

昨天的流程每天早上會自己向助理問三題,把回答存成一份日報放在 BigQuery,可是要看日報還是得有人打開 BigQuery 去查,流程三題全失敗的那一天也只有 Workflows 執行清單上多一個紅字,Day 09 找出來的成效異常更是安靜地躺在 diag_summary 裡,沒有人去查就沒有人知道,寫好了沒人看和沒寫其實差不多。

同事每天一定會打開的地方是 Slack,所以今天把這些結果直接送進團隊的 Slack 頻道,這件事聽起來只是多呼叫一個網址,實際動手才發現要先回答幾個問題,那個網址要放在哪裡才不會外流、同一筆異常每天早上都還在期間裡要不要每天送一次、送 Slack 失敗了會不會連已經寫好的日報都不見、流程自己壞掉的時候又由誰來通知。

今天的做法是在昨天的流程裡加一段送 Slack 的子流程,排程和身分都沿用昨天的設定,成效異常直接用 Day 09 算好的判讀,送警報這一段不再呼叫模型。

今日核心目標:

  1. 把日報、沒寫完的日報、流程中途出錯、成效異常四種情況送進 Slack,同一件事只通知一次
  2. 把 Slack 的網址當成密鑰放進 Secret Manager,流程定義、環境變數、BigQuery 與流程自己寫的紀錄裡都沒有它
  3. 通知送不出去的時候日報照樣留著,這次執行以失敗結束讓人看得見

2. 系統架構全景與設計理念

圖一:Cloud Scheduler 啟動 Workflows 流程,流程向 Day 26 的助理問三題寫成日報,再從 Day 09 的異常表挑出該送的警報,送之前先查通知紀錄表有沒有送過,要送的時候才向 Secret Manager 拿 Slack 的網址,送完在通知紀錄表留一列

昨天的架構幾乎沒有動,今天多出來的是右半邊的四樣東西。

新增的東西 放在哪裡 負責什麼
送 Slack 的子流程 notify 昨天的 daily_report.yaml 查有沒有送過、換掉特殊字元、拿網址、送出、留紀錄
通知紀錄表 ops_notifications BigQuery martech_dw 每送一次留一列,「同一件事只通知一次」就靠它
警報文字 v_anomaly_alerts BigQuery martech_dw 把 Day 09 的異常與判讀組成一段要送出去的文字
密鑰 slack-webhook-url Secret Manager 放 Slack 的網址,除了專案擁有者只有流程的服務帳號讀得到

這個設計有三個刻意的安排。

第一,不另外開一個新的流程,昨天的流程已經有排程、有服務帳號、有重試的規則,日報寫完的那一刻也只有它知道結果,另外開一個流程去讀日報表的話要多處理兩個流程誰先誰後,所以讀者只要重新部署一次就好。

第二,成效異常的警報不呼叫模型,Day 09 已經請 Gemini 判讀過每一筆異常,判讀、信心和建議先查的方向都存在 mart_diagnosis,今天只是把它組成一段文字送出去,送警報這一段新台幣 0 元,要花錢的只有昨天就在的那份日報。

第三,通知是附加在日報後面的動作,不是日報的一部分,送 Slack 失敗不能讓已經寫好的日報跟著不見,但也不能安靜地放過,所以送失敗時日報照樣留在表裡,這次執行以失敗結束,執行清單上看得到紅字。


3. 核心技術深度拆解

3.1 四種通知各在什麼時候送

通知 什麼時候送 同一件事送幾次
日報 三題都答完 每天一次,當天稍後重跑被跳過時,Slack 如果還沒收過會補送,帶 force 重問的話會再送一次
日報沒有寫完 三題沒有全部答完 每一次沒寫完的執行都送
流程中途出錯 流程丟出沒料到的錯 每一次都送
成效異常 資料最新的一天(或指定的 as_of)落在某一筆異常的期間裡,或期間結束後 7 天內 每一筆異常只送一次,帶 renotify 才重送

日報和異常是「同一件事只要知道一次」的通知,沒寫完和出錯則是「每一次都要知道」的通知,早上 08:30 沒寫完、09:00 有人手動重跑又沒寫完,這是兩件事,兩次都要送。

異常的期間後面多留 7 天,是因為全站的異常期間只有一天,那天的排程剛好沒跑、或 Day 09 的診斷晚一兩天才重跑,只在期間內才送的話這筆異常就永遠送不出去了。

啟動參數寫錯的情況不送,例如有人把參數寫成一串文字而不是 JSON 物件,那是啟動的人自己當下就看得到的錯,執行一樣會失敗,不需要再吵整個頻道。

3.2 Incoming Webhook 的網址本身就是密鑰

Slack 讓外部程式送訊息最簡單的方式是 Incoming Webhook,在 Slack 建一個 app、指定一個頻道,Slack 會給一個網址,對這個網址送一段 JSON,訊息就出現在那個頻道。

這個網址沒有帳號密碼,拿到的人就能往那個頻道發訊息,Slack 官方文件也特別提醒網址裡含有密鑰,不要公開,連公開的版本控制都不要放,而且 Slack 會主動找出外流的網址並撤銷,另外一個 Webhook 只能送到建立時選的那個頻道,送的時候改不了頻道,所以頻道選錯了要重建一個。

所以今天把這個網址當成密碼來管,不寫在程式裡,不放在環境變數,也不讓它出現在任何紀錄裡。

3.3 網址放在 Secret Manager,要送的時候才去拿

網址放在 Secret Manager 的密鑰 slack-webhook-url,Day 25 的 Terraform 建好的是一個空的密鑰,內容要自己放,除了本來就讀得到的專案擁有者,只有流程用的 martech-pipeline-runner 這個服務帳號有讀取權。

部署時寫進流程環境變數的是密鑰的名稱和版本,不是網址本身,流程要送訊息的那一刻才向 Secret Manager 拿。

- hook:
    call: googleapis.secretmanager.v1.projects.secrets.versions.accessString
    args:
      secret_id: ${sys.get_env("SLACK_SECRET")}
      version: ${sys.get_env("SLACK_SECRET_VERSION")}
      project_id: ${project}
    result: hook_url
# 中間省略一步:去掉網址前後多帶到的空白與換行
- post:
    call: http.post
    args:
      url: ${hook_url}
      body:
        text: ${safe}
      timeout: 20
    result: posted

accessString 是 Workflows 內建的 Secret Manager 連接器,拿回來的是解碼好的字串,網址只活在送訊息這個子流程的變數裡,子流程結束就不見了,不回傳給呼叫的人,也不寫進流程自己的紀錄。

還有一個容易漏掉的地方,Workflows 可以把每一步的請求記到 Cloud Logging,叫做 call logging,打開之後拿密鑰那一步的回傳值和送 Slack 那一步的請求網址都會被原封不動記下來,所以部署腳本明確寫了 --call-log-level log-none,要照實說的是這只管得到流程的預設值,有啟動權限的人可以在啟動單一次執行時自己把紀錄層級調高、或把執行歷程調成詳細,那一次的網址就可能出現在紀錄或執行歷程裡,所以啟動流程與看紀錄的權限都要當成「看得到網址」來管。

送失敗的時候流程只記錯在哪一段、哪一種錯,例如 slack_http_404、secret_http_403,不記錯誤訊息的原文,因為 HTTP 錯誤的原文裡可能帶著請求的網址。

3.4 同一件事只通知一次

成效異常的期間加上後面 7 天,最長會連續十幾天都符合送出的條件,排程每天早上跑一次,什麼都不擋的話同一筆異常會每天早上送一次,大家很快就會把這個頻道關成靜音,真的有新狀況時反而沒人看到。

所以每一次通知都在 ops_notifications 留一列,送之前先用一個鍵去查有沒有送成功過。

通知 用來判斷「同一件事」的鍵
成效異常 anomaly: 加上異常的編號
日報 daily_report: 加上日期
日報沒有寫完 report_incomplete: 加上執行編號
流程中途出錯 不查,每次都送

流程中途出錯的通知不查紀錄,原因是流程出錯的時候 BigQuery 可能正是出錯的地方,查不到紀錄就不送的話,最需要通知的時候反而送不出去,日報帶 force、異常帶 renotify 的時候也不查,因為那是有人明確要重送,其他情況則反過來,查紀錄這一步出錯或沒有在時限內完成就不送、記成失敗,寧可這一次漏送被看見,也不要每天重複送同一則。

這個做法有一個縫,訊息已經送出去但寫紀錄失敗的話,下一次會以為沒送過而再送一次,兩次執行幾乎同時啟動的話同一筆異常也可能各送一次,這兩種情況多送一則的代價比漏送小,所以留著並寫在 README 的限制裡。

3.5 送出前先換掉三個字元

Slack 的訊息格式裡 &、<、> 有特殊意義,連結和提醒整個頻道都是用角括號寫的,官方的格式說明要求這三個字元當成一般文字時要換成 &amp;、&lt;、&gt;。

日報的內容是助理寫的,也就是模型寫的,不能假設裡面不會出現這幾個字,Day 23 談過資料被外人寫進奇怪內容的風險,廣告活動的備註、網址參數都可能有角括號,所以流程在送出前一律先換掉。

- escape:
    assign:
      - safe: ${text.replace_all(text.replace_all(text.replace_all(message, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")}

& 要第一個換,先換角括號的話換出來的 &lt; 又會被換成 &amp;lt;。

粗體則是另外一回事,Slack 的粗體是用星號把文字包起來,今天第一則警報送出去之後,頻道裡看到的是原樣的星號,原因是收尾的星號後面接了全形冒號,Slack 沒有把它當成粗體,改成星號後面接換行或空白就正常了,所以標題改成獨立一行,這件事在 6. 避坑再說一次。

3.6 通知失敗不能弄丟日報

送 Slack 的子流程自己不丟出錯誤,任何一段出錯都只回報「沒送出去」給呼叫的人,這樣做的原因是流程丟出錯誤就會停下來,日報如果還沒寫完,後面的題目就不會再問了。

整次執行最後才結算,三題沒有全部答完、或是有任何一則該送的通知沒送出去,這次執行就以失敗結束。

情況 日報表 Slack 執行清單
三題都答完,通知送出 三列 ok 收到日報 成功
三題都答完,通知沒送出去 三列 ok 沒收到 失敗
有一題沒答完 兩列 ok、一列 failed 收到「日報沒有寫完」 失敗
流程中途出錯 出錯前寫好的列 收到「流程中途出錯」(啟動參數寫錯或指定不送時不送) 失敗

送 Slack 遇到 HTTP 429(Slack 說太忙)、500、502、503、504 或連線沒建立的時候會重試 2 次,間隔固定是 5 秒、10 秒,Slack 官方的頻率限制是每秒 1 則,短暫超過可以接受,超過太多會回 429 並在 Retry-After 告訴你幾秒後再試,流程沒有照這個秒數等,因為一次執行最多送幾則,碰不到這個限制,要照實說的是 5xx 有可能是 Slack 收到了才出錯,這時重試會讓同一則訊息出現兩次,和漏送相比這個代價比較小。

還有一種情況這個設計管不到,流程壞到連 Slack 都送不出去,例如密鑰被刪了或網址失效,這時候只有執行清單上看得到失敗,正式環境建議另外用 Cloud Monitoring 對失敗的執行設警報,讓 Google Cloud 自己寄信通知。

3.7 實際收到的訊息

圖二:Slack 頻道實際收到的兩則成效異常警報,左邊是 11:18 送出的 2026-08-27 全站追蹤碼失效,收尾的星號後面接了全形冒號所以星號原樣印出來,右邊是修正後 12:14 送出的 2026-08-12 meta-trn-prospecting 競價變貴,標題獨立一行

合成資料停在 2026-09-16,所以流程多了一個 as_of 參數,可以把過去的某一天當成今天去看有沒有成效異常,今天實際重播了兩天,下表是三個日子各會挑出哪一筆異常。

當成今天的日子 會送出的警報 Day 09 的判讀
2026-08-27 全站 2026-08-27 追蹤碼失效,信心 0.95
2026-08-12 廣告群組 meta-trn-prospecting,08-10 到 08-16 這一週 競價變貴,信心 0.85
不帶,用資料最新的 09-16 廣告群組 meta-evg-prospecting,09-14 到 09-20 這一週 素材疲乏,信心 0.80

11:18 把 2026-08-27 當成今天,Slack 收到追蹤碼失效的警報,也就是圖二左邊那一則,星號原樣印出來之後改了格式重新部署,12:14 把 2026-08-12 當成今天,收到的是圖二右邊那一則,每次重播也都會多一則【測試】的「日報沒有寫完」,因為測試模式讓三題故意失敗,四則都是 HTTP 200,12:19 再把 2026-08-27 當成今天跑一次,這次只收到【測試】那一則,追蹤碼失效的警報沒有再送,紀錄表也沒有多出新的異常列。


4. FinOps 成本防護實踐:三道防線體系

  1. 第一道防線:善用免費額度:Slack 的 Incoming Webhook 不收費,Secret Manager 每個帳單帳戶每月前 6 個啟用中的密鑰版本與前 10,000 次存取免費,今天只有 1 個版本,每送一則通知存取一次,Workflows 呼叫 googleapis.com 算內部步驟,送 Slack 的那一步呼叫外部網址算外部步驟,每月有 2,000 個外部步驟免費,每天送幾則一個月下來也在額度內,通知紀錄的查詢與寫入在 BigQuery 的每月免費額度內
  2. 第二道防線:架構層被動成本防護:警報不呼叫模型,判讀是 Day 09 存好的,送 Slack 這一步也不呼叫模型,所以今天的改動沒有讓每天的模型費用多出一塊錢,同一件事只通知一次的設計也讓日報與異常不會因為重跑而一直送
  3. 第三道防線:Cloud Billing 預算警報:沿用 Day 03 的預算警報,今天的測試都讓三題故意失敗,一次都沒有問助理
項目 今天的費用(新台幣) 說明
模型呼叫 0 元 今天的測試都沒有問助理,真的跑一份日報開跑前印的估價是 0.45 到 0.90 元,留到全系列完成後的驗收再跑
Secret Manager 0 元 1 個版本,每送一則存取一次
Workflows 0 元 都在免費額度內
BigQuery 0 元 查詢與寫入都在免費額度內
Slack 0 元 Incoming Webhook 不收費

5. Cloud Shell 實戰演練:把日報與異常送進 Slack

5.1 事前準備

  • 在 Slack 建一個 app、打開 Incoming Webhooks、選好要送的頻道,複製產生的網址,這個頻道建議先用只有自己在的測試頻道
  • 在 ~/ai-driven-martech-pipeline 執行 git pull,取得 Day 28 改過的 workflows/ 與部署腳本
  • 昨天的流程與排程要還在,Day 09 的異常診斷要跑過,Day 25 的 Terraform 要已經套用,空的密鑰 slack-webhook-url 和讀取權是那一次建的

5.2 路線 A|一行指令

先照 5.3 步驟 1 把網址放進密鑰,再執行這一行。

cd ~/ai-driven-martech-pipeline && git pull && bash scripts/deploy_daily_report.sh && bash workflows/run.sh --free && bash workflows/run.sh --slack-test

git pull 與部署腳本重新部署,--free 不花錢也不送 Slack,確認昨天的行為沒有被改壞,--slack-test 會停下來問你一次,輸入 yes 才送出一則標明【測試】的訊息,全程不呼叫模型。

5.3 路線 B|一步一步看

步驟 1:把網址放進 Secret Manager

read -r -s -p 'Webhook 網址:' HOOK && printf '%s' "$HOOK" | gcloud secrets versions add slack-webhook-url --data-file=- ; unset HOOK

read -s 讓貼上的網址不顯示在畫面上,也不會留在指令紀錄裡,直接把網址打在指令列上的話,~/.bash_history 裡會留一份,printf '%s' 不在最後加換行,流程拿到網址後也會再去掉空白與換行,從網頁複製時多帶到的字不會造成問題。

步驟 2:重新部署

bash scripts/deploy_daily_report.sh

腳本會確認密鑰有啟用中的版本、流程的服務帳號讀得到它,但不會讀網址的內容,接著建立通知紀錄表與警報的 view、重新部署流程,排程維持原本的時間與狀態。

gcloud workflows deploy martech-daily-report \
  --location us-central1 \
  --source workflows/daily_report.yaml \
  --service-account martech-pipeline-runner@你的專案.iam.gserviceaccount.com \
  --set-env-vars "ASSISTANT_URL=助理的網址,DATASET=martech_dw,SLACK_SECRET=slack-webhook-url,SLACK_SECRET_VERSION=latest" \
  --call-log-level log-none

和昨天比多了兩個環境變數和最後一行,環境變數放的是密鑰的名稱與版本,--call-log-level log-none 讓流程預設不把每一步的請求寫進 Cloud Logging。

步驟 3:不花錢的測試

bash workflows/run.sh --free
bash workflows/run.sh --slack-test

--free 三題都改打不存在的路徑,不送 Slack,預期三題都是 http_404、22 項檢查全部通過,--slack-test 一樣讓三題失敗但會送出一則「【測試】日報沒有寫完」,輸入 yes 才送,這次執行預期以失敗結束,到 Slack 頻道確認看得到這則訊息。

步驟 4:重播過去的成效異常

bash workflows/run.sh --slack-test --as-of 2026-08-27
bash workflows/run.sh --slack-test --as-of 2026-08-12

把那一天當成今天去看有沒有成效異常,會一起送出那一天的警報,警報沒有【測試】標記,送過一次之後就不會再送,同一個指令再跑一次,那筆異常不會再送,紀錄表也不會多出新的一列,執行結果的 alerts_sent 是 0,要重送得加 --renotify。

步驟 5:實際跑一次

bash workflows/run.sh

和昨天一樣先印估價,輸入 yes 才問三題,答完之後日報會出現在 Slack,今天已經有完整的日報時流程直接跳過,Slack 還沒收過的話會補送一次,這一步不呼叫模型,要留意的是不帶 --as-of 的這一次會用資料最新的一天去看異常,合成資料停在 09-16,所以會送出 meta-evg-prospecting 那一週素材疲乏的警報,沒有【測試】標記而且只送一次,第一次跟著做請在測試頻道跑。

5.4 驗證成果

  • 重新部署後不花錢的測試三題都是 http_404,22 項檢查全部通過,沒有送出任何 Slack 訊息
  • 把 2026-08-27 當成今天重播,Slack 收到全站追蹤碼失效的警報與一則【測試】訊息,兩則都回 HTTP 200,通知紀錄表多了兩列 sent
  • 那一則警報的星號原樣印出來,改成標題獨立一行重新部署之後,把 2026-08-12 當成今天重播,收到 meta-trn-prospecting 競價變貴的警報與一則【測試】訊息,一樣兩則 HTTP 200,這次標題正常顯示成粗體
  • 再把 2026-08-27 當成今天跑一次,追蹤碼失效的警報沒有重送,只收到一則【測試】,紀錄表沒有多出新的異常列
  • 每次跑完的檢查第 19 項都是 0,日報表、通知紀錄表裡沒有任何一列含有 Slack 的網址
  • 還沒實際跑過的:三題都答完的日報送進 Slack、密鑰拿不到時的失敗路徑、流程中途出錯的通知,這三條路只有寫進流程並經過獨立審查,會在全系列完成後的驗收一起跑

5.5 用完後怎麼處理

gcloud scheduler jobs pause martech-daily-report-morning --location us-central1

排程暫停之後不會再自己跑,也就不會再送訊息,不想再收到任何通知的話,到 Slack 的 app 設定把這個 Webhook 移除,網址會立刻失效,密鑰裡的那個版本也可以停用。

gcloud secrets versions list slack-webhook-url
gcloud secrets versions disable 版本號 --secret slack-webhook-url

先列出版本,把啟用中的每一個都停用,停用之後流程送通知時會拿不到網址,之後要重新部署的話腳本也會因為沒有啟用中的版本而停下來,執行以失敗結束,日報照樣寫進表裡,我自己的排程維持暫停。


6. 工程實務避坑指南

  1. 網址不要打在指令列上:直接把網址寫在 gcloud secrets versions add 的指令裡,指令紀錄會留一份,換人用這台機器、或把指令紀錄貼給別人看的時候網址就外流了
  2. call logging 會記下請求的網址:開了之後送 Slack 那一步的完整網址會進到 Cloud Logging,看得到紀錄的人就看得到網址,部署時要明確關掉,而且要記得單一次執行還能自己調高
  3. 錯誤訊息的原文也可能帶著網址:HTTP 錯誤的內容常常包含請求的網址,流程出錯時只記錯在哪一段和哪一種錯,不把原文寫進紀錄表或送進 Slack
  4. 粗體的星號後面不能接全形標點:*成效異常*: 送到 Slack 會原樣顯示星號,收尾的星號後面要接空白或換行,開頭的星號前面也要是空白或行首,【測試】這種全形括號後面緊接星號一樣會失效,我是在第一則警報送出去之後才發現的,那一則因為「只送一次」而不會重送,頻道裡會一直留著舊格式
  5. 模型寫的內容要先換掉 & < >:角括號在 Slack 裡是連結與提醒全頻道的語法,日報內容是助理寫的,送出前一律換成 &amp;、&lt;、&gt;,& 要第一個換
  6. 不擋重複的話警報會變成噪音:異常的期間加上後面 7 天會連續十幾天符合條件,每天送一次的話大家很快會把頻道關成靜音,所以每一筆異常只送一次
  7. 測試不要把真的警報送掉:重播異常送出去的警報就算送過了,之後同一筆異常真的需要通知時不會再送,所以 --slack-test 預設不看成效異常,要加 --as-of 才會一起送,正式環境請在測試頻道做這一步
  8. 通知失敗不能弄丟日報:送 Slack 的子流程自己不丟錯誤,失敗只回報給呼叫的人,整次執行最後才結算成功或失敗
  9. 流程壞到送不出通知時要有第二條路:密鑰被刪或網址失效時 Slack 收不到任何東西,只有執行清單看得到,正式環境要用 Cloud Monitoring 對失敗的執行另外設警報
  10. Webhook 選定頻道後改不了:送的時候不能指定別的頻道,要換頻道得重建一個 Webhook 並把新網址放進密鑰的新版本,舊的版本記得停用
  11. 查紀錄沒查完不能當成沒送過:BigQuery 的查詢有時間上限,時限內沒完成時回傳的結果裡沒有任何一列,我原本的寫法只在「查完而且有送過」時才停下,沒查完就會當成沒送過而照送,這是另一方檢查時找到的,改成沒查完就算查紀錄失敗、這一次不送

7. 總結與明日預告

今天在昨天的流程裡加了一段送 Slack 的子流程,日報、沒寫完的日報、流程中途出錯、成效異常四種情況都會送到團隊的頻道,Slack 的網址只放在 Secret Manager,流程要送的時候才去拿,同一件事靠通知紀錄表只送一次,送失敗時日報照樣留著、執行以失敗結束,警報直接用 Day 09 的判讀,送警報這一段不呼叫模型,今天部署三次、實際跑了四次,一次不送 Slack 的測試、兩次重播與一次重複檢查,送出五則訊息,沒有呼叫模型,費用新台幣 0 元。

回到篇名,「第一時間」今天做到的是排程跑完的那一刻,排程一天跑一次,所以最慢是隔天早上 08:30 才收到,要更快得把排程調密,而每跑一次日報就是一次模型的費用,另一個限制是異常本身是 Day 09 的批次算好的,資料更新之後要先重跑 Day 09 的診斷,流程不會自己重算,接上每天更新的資料以後,這兩件事是下一步要處理的。

送到 Slack 的內容就離開了 Google Cloud,日報裡有廣告花費與訂單數字,這個頻道要開給誰看要先想清楚,這三題不會碰到顧客個資,但花費數字也不是每個人都該看到的。

明日預告:Day 29《做完一套 AI 行銷系統後我踩到的坑與心路歷程》,回頭整理這 28 天踩過的坑,哪些設計後來證明是對的,哪些當初想得太簡單。


上一篇
Day 27 | 早上一進辦公室,AI 行銷日報已經自動寫好了
系列文
AI-Driven MarTech:用 Google Cloud + Vertex AI 打造全自動廣告歸因與多模態素材分析系統 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言