昨天的流程每天早上會自己向助理問三題,把回答存成一份日報放在 BigQuery,可是要看日報還是得有人打開 BigQuery 去查,流程三題全失敗的那一天也只有 Workflows 執行清單上多一個紅字,Day 09 找出來的成效異常更是安靜地躺在 diag_summary 裡,沒有人去查就沒有人知道,寫好了沒人看和沒寫其實差不多。
同事每天一定會打開的地方是 Slack,所以今天把這些結果直接送進團隊的 Slack 頻道,這件事聽起來只是多呼叫一個網址,實際動手才發現要先回答幾個問題,那個網址要放在哪裡才不會外流、同一筆異常每天早上都還在期間裡要不要每天送一次、送 Slack 失敗了會不會連已經寫好的日報都不見、流程自己壞掉的時候又由誰來通知。
今天的做法是在昨天的流程裡加一段送 Slack 的子流程,排程和身分都沿用昨天的設定,成效異常直接用 Day 09 算好的判讀,送警報這一段不再呼叫模型。
今日核心目標:
昨天的架構幾乎沒有動,今天多出來的是右半邊的四樣東西。
| 新增的東西 | 放在哪裡 | 負責什麼 |
|---|---|---|
送 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 失敗不能讓已經寫好的日報跟著不見,但也不能安靜地放過,所以送失敗時日報照樣留在表裡,這次執行以失敗結束,執行清單上看得到紅字。
| 通知 | 什麼時候送 | 同一件事送幾次 |
|---|---|---|
| 日報 | 三題都答完 | 每天一次,當天稍後重跑被跳過時,Slack 如果還沒收過會補送,帶 force 重問的話會再送一次 |
| 日報沒有寫完 | 三題沒有全部答完 | 每一次沒寫完的執行都送 |
| 流程中途出錯 | 流程丟出沒料到的錯 | 每一次都送 |
| 成效異常 | 資料最新的一天(或指定的 as_of)落在某一筆異常的期間裡,或期間結束後 7 天內 |
每一筆異常只送一次,帶 renotify 才重送 |
日報和異常是「同一件事只要知道一次」的通知,沒寫完和出錯則是「每一次都要知道」的通知,早上 08:30 沒寫完、09:00 有人手動重跑又沒寫完,這是兩件事,兩次都要送。
異常的期間後面多留 7 天,是因為全站的異常期間只有一天,那天的排程剛好沒跑、或 Day 09 的診斷晚一兩天才重跑,只在期間內才送的話這筆異常就永遠送不出去了。
啟動參數寫錯的情況不送,例如有人把參數寫成一串文字而不是 JSON 物件,那是啟動的人自己當下就看得到的錯,執行一樣會失敗,不需要再吵整個頻道。
Slack 讓外部程式送訊息最簡單的方式是 Incoming Webhook,在 Slack 建一個 app、指定一個頻道,Slack 會給一個網址,對這個網址送一段 JSON,訊息就出現在那個頻道。
這個網址沒有帳號密碼,拿到的人就能往那個頻道發訊息,Slack 官方文件也特別提醒網址裡含有密鑰,不要公開,連公開的版本控制都不要放,而且 Slack 會主動找出外流的網址並撤銷,另外一個 Webhook 只能送到建立時選的那個頻道,送的時候改不了頻道,所以頻道選錯了要重建一個。
所以今天把這個網址當成密碼來管,不寫在程式裡,不放在環境變數,也不讓它出現在任何紀錄裡。
網址放在 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 錯誤的原文裡可能帶著請求的網址。
成效異常的期間加上後面 7 天,最長會連續十幾天都符合送出的條件,排程每天早上跑一次,什麼都不擋的話同一筆異常會每天早上送一次,大家很快就會把這個頻道關成靜音,真的有新狀況時反而沒人看到。
所以每一次通知都在 ops_notifications 留一列,送之前先用一個鍵去查有沒有送成功過。
| 通知 | 用來判斷「同一件事」的鍵 |
|---|---|
| 成效異常 | anomaly: 加上異常的編號 |
| 日報 | daily_report: 加上日期 |
| 日報沒有寫完 | report_incomplete: 加上執行編號 |
| 流程中途出錯 | 不查,每次都送 |
流程中途出錯的通知不查紀錄,原因是流程出錯的時候 BigQuery 可能正是出錯的地方,查不到紀錄就不送的話,最需要通知的時候反而送不出去,日報帶 force、異常帶 renotify 的時候也不查,因為那是有人明確要重送,其他情況則反過來,查紀錄這一步出錯或沒有在時限內完成就不送、記成失敗,寧可這一次漏送被看見,也不要每天重複送同一則。
這個做法有一個縫,訊息已經送出去但寫紀錄失敗的話,下一次會以為沒送過而再送一次,兩次執行幾乎同時啟動的話同一筆異常也可能各送一次,這兩種情況多送一則的代價比漏送小,所以留著並寫在 README 的限制裡。
Slack 的訊息格式裡 &、<、> 有特殊意義,連結和提醒整個頻道都是用角括號寫的,官方的格式說明要求這三個字元當成一般文字時要換成 &、<、>。
日報的內容是助理寫的,也就是模型寫的,不能假設裡面不會出現這幾個字,Day 23 談過資料被外人寫進奇怪內容的風險,廣告活動的備註、網址參數都可能有角括號,所以流程在送出前一律先換掉。
- escape:
assign:
- safe: ${text.replace_all(text.replace_all(text.replace_all(message, "&", "&"), "<", "<"), ">", ">")}
& 要第一個換,先換角括號的話換出來的 < 又會被換成 &lt;。
粗體則是另外一回事,Slack 的粗體是用星號把文字包起來,今天第一則警報送出去之後,頻道裡看到的是原樣的星號,原因是收尾的星號後面接了全形冒號,Slack 沒有把它當成粗體,改成星號後面接換行或空白就正常了,所以標題改成獨立一行,這件事在 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 自己寄信通知。
合成資料停在 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 當成今天跑一次,這次只收到【測試】那一則,追蹤碼失效的警報沒有再送,紀錄表也沒有多出新的異常列。
googleapis.com 算內部步驟,送 Slack 的那一步呼叫外部網址算外部步驟,每月有 2,000 個外部步驟免費,每天送幾則一個月下來也在額度內,通知紀錄的查詢與寫入在 BigQuery 的每月免費額度內| 項目 | 今天的費用(新台幣) | 說明 |
|---|---|---|
| 模型呼叫 | 0 元 | 今天的測試都沒有問助理,真的跑一份日報開跑前印的估價是 0.45 到 0.90 元,留到全系列完成後的驗收再跑 |
| Secret Manager | 0 元 | 1 個版本,每送一則存取一次 |
| Workflows | 0 元 | 都在免費額度內 |
| BigQuery | 0 元 | 查詢與寫入都在免費額度內 |
| Slack | 0 元 | Incoming Webhook 不收費 |
~/ai-driven-martech-pipeline 執行 git pull,取得 Day 28 改過的 workflows/ 與部署腳本slack-webhook-url 和讀取權是那一次建的先照 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 才送出一則標明【測試】的訊息,全程不呼叫模型。
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' 不在最後加換行,流程拿到網址後也會再去掉空白與換行,從網頁複製時多帶到的字不會造成問題。
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。
bash workflows/run.sh --free
bash workflows/run.sh --slack-test
--free 三題都改打不存在的路徑,不送 Slack,預期三題都是 http_404、22 項檢查全部通過,--slack-test 一樣讓三題失敗但會送出一則「【測試】日報沒有寫完」,輸入 yes 才送,這次執行預期以失敗結束,到 Slack 頻道確認看得到這則訊息。
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。
bash workflows/run.sh
和昨天一樣先印估價,輸入 yes 才問三題,答完之後日報會出現在 Slack,今天已經有完整的日報時流程直接跳過,Slack 還沒收過的話會補送一次,這一步不呼叫模型,要留意的是不帶 --as-of 的這一次會用資料最新的一天去看異常,合成資料停在 09-16,所以會送出 meta-evg-prospecting 那一週素材疲乏的警報,沒有【測試】標記而且只送一次,第一次跟著做請在測試頻道跑。
http_404,22 項檢查全部通過,沒有送出任何 Slack 訊息sent
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
先列出版本,把啟用中的每一個都停用,停用之後流程送通知時會拿不到網址,之後要重新部署的話腳本也會因為沒有啟用中的版本而停下來,執行以失敗結束,日報照樣寫進表裡,我自己的排程維持暫停。
gcloud secrets versions add 的指令裡,指令紀錄會留一份,換人用這台機器、或把指令紀錄貼給別人看的時候網址就外流了*成效異常*: 送到 Slack 會原樣顯示星號,收尾的星號後面要接空白或換行,開頭的星號前面也要是空白或行首,【測試】這種全形括號後面緊接星號一樣會失效,我是在第一則警報送出去之後才發現的,那一則因為「只送一次」而不會重送,頻道裡會一直留著舊格式&、<、>,& 要第一個換--slack-test 預設不看成效異常,要加 --as-of 才會一起送,正式環境請在測試頻道做這一步今天在昨天的流程裡加了一段送 Slack 的子流程,日報、沒寫完的日報、流程中途出錯、成效異常四種情況都會送到團隊的頻道,Slack 的網址只放在 Secret Manager,流程要送的時候才去拿,同一件事靠通知紀錄表只送一次,送失敗時日報照樣留著、執行以失敗結束,警報直接用 Day 09 的判讀,送警報這一段不呼叫模型,今天部署三次、實際跑了四次,一次不送 Slack 的測試、兩次重播與一次重複檢查,送出五則訊息,沒有呼叫模型,費用新台幣 0 元。
回到篇名,「第一時間」今天做到的是排程跑完的那一刻,排程一天跑一次,所以最慢是隔天早上 08:30 才收到,要更快得把排程調密,而每跑一次日報就是一次模型的費用,另一個限制是異常本身是 Day 09 的批次算好的,資料更新之後要先重跑 Day 09 的診斷,流程不會自己重算,接上每天更新的資料以後,這兩件事是下一步要處理的。
送到 Slack 的內容就離開了 Google Cloud,日報裡有廣告花費與訂單數字,這個頻道要開給誰看要先想清楚,這三題不會碰到顧客個資,但花費數字也不是每個人都該看到的。
明日預告:Day 29《做完一套 AI 行銷系統後我踩到的坑與心路歷程》,回頭整理這 28 天踩過的坑,哪些設計後來證明是對的,哪些當初想得太簡單。