1. 為什麼在 ChatGPT App 一定要用結構化日誌
想像產品經理來訊息你:「使用者抱怨,選擇禮物時有時會顯示空清單,有時 checkout 會掛掉。可以在明天的 demo 前修好嗎?」你手上有:
- ChatGPT,有時會呼叫你的 App,有時則不會。
- 沙箱中的小工具。
- MCP 伺服器,會呼叫外部商品資料庫與 ACP。
- 支付系統的 webhook。
而你只有零散的文字日誌,例如 MCP 某處的 「something went wrong」 與後端某處的 「order failed」。在並行請求下,這會變成一團亂:無法判斷哪條日誌屬於哪位使用者、哪個請求。
這正是為何需要結構化 JSON 日誌與統一的 trace_id,它們能幫你:
- 用單一識別碼看見完整鏈路:從 ChatGPT 請求到 webhook "order.created";
- 依服務、工具、使用者、情境過濾日誌;
- 快速回答「為什麼 checkout 掛了」以及「代理在開始產生幻覺前做了什麼」。
也就是說,目標很簡單:讓生產環境的 GiftGenius 能像一般微服務應用一樣好除錯與監控。
2. 字串式 vs 結構化日誌:為何 console.log("糟了") 已經不管用了
在一般的 Next.js 開發中,許多人只用字串式日誌:印出人類可讀的句子,偶爾附帶幾個值。在單一服務裡還算勉強,但在 ChatGPT App 的堆疊裡,這種日誌很快就變成一鍋粥。
文字日誌就是檔案或主控台的一行。例如:
console.error(`Error in suggestGifts for user ${userId}: ${error.message}`);
當這類訊息達到十萬筆,要找出「昨天在 checkout 中 userId=… 的所有 MCP 錯誤」已經不容易;而要自動建立工具錯誤的儀表板更是幾乎不可能。
結構化日誌是 JSON 物件;除了訊息文字,還包含一系列欄位:等級、時間、服務、識別碼、技術與業務情境。前例的對應做法:
logger.error({
message: "suggest_gifts failed",
user_id: userId,
trace_id,
service: "mcp",
tool_name: "suggest_gifts",
error_message: error.message,
});
每個欄位都會被日誌系統(ELK、Loki、Better Stack、Datadog 等)索引,之後就能寫出類似 service="mcp" AND level="error" AND tool_name="suggest_gifts" 的查詢,或直接用 trace_id="..." 來搜尋。
為了更直觀,來看一個小表格。
| 比較項 | 字串式日誌 | 結構化(JSON)日誌 |
|---|---|---|
| 解析 | 手動,靠 regex | 依欄位自動解析 |
| 欄位搜尋 | 複雜的 regexp 查詢 | 簡單的表達式 field=value |
| 彙總與儀表板 | 困難,充滿權宜之計 | 簡單:count() 、group by field |
| 加註情境 | 寫在訊息文字裡 | 用新欄位,無須改 schema |
| 請求關聯 | 在並行請求時幾乎不可能 | 直接以 trace_id/request_id 搜尋 |
在 LLM 應用的世界裡,一半的問題不是「500 錯誤」,而是「模型呼叫錯了工具」。沒有結構化日誌,你幾乎是瞎的。
3. ChatGPT App 的 JSON 日誌剖析
接下來我們約定一個你會在 GiftGenius 所有層中使用的「最小標準」日誌紀錄。它不完美,但能涵蓋 80% 的需求。
我們把日誌欄位分成幾組。
技術欄位
技術欄位能讓可觀測工具理解紀錄是從哪裡來的。
可以用 TypeScript 型別描述:
type LogLevel = "debug" | "info" | "warn" | "error";
interface BaseLogFields {
timestamp: string; // ISO 8601 UTC
level: LogLevel; // "info", "error"...
service: string; // "app-widget", "mcp", "agent", "commerce", "webhook"
env: "dev" | "staging" | "prod";
message: string; // 事件的簡短說明
}
timestamp 最好用 UTC 的 ISO 格式("2025-11-21T10:15:30.123Z"),這樣不同服務就能不必處理時區也能按時間排序。service 與 env 能幫你分開例如生產環境的 MCP 與開發環境的小工具日誌。這在未來你想整合 OpenTelemetry 並使用通用慣例如 service.name、service.version 等時特別有用。
關聯欄位
這是本講最重要的部分。沒有它們,你無法把事件彼此關聯起來。
把介面擴充:
interface CorrelationFields {
trace_id: string; // 整個情境的端到端 ID
span_id?: string; // (可選) 特定操作的 ID
parent_span_id?: string; // (可選) 父操作
request_id?: string; // 本地的 HTTP 請求或 tool-call ID
agent_run_id?: string; // 代理執行的 ID(如有)
tool_call_id?: string; // 特定工具呼叫的 ID
checkout_session_id?: string; // ACP/支付工作階段的 ID
}
trace_id 是主角。它在所有屬於同一情境的日誌中都必須相同,例如:「使用者請我們選禮物 → 我們選完 → 建立訂單 → 收到 webhook」。span_id 與 parent_span_id 可用來構建類似分散式追蹤的「操作樹」,但起步時只用 trace_id 與 request_id 也夠了。
業務情境
沒有業務情境的技術日誌就會變成「某時某地發生了某事」。我們需要知道涉及哪位使用者、在哪個步驟。
再擴充介面:
interface BusinessFields {
user_id?: string; // 匿名 ID,非 email
tenant_id?: string; // 組織/帳戶(若為 B2B)
flow?: string; // 例如 "gift_recommendation" 或 "checkout"
step?: string; // 例如 "collect_requirements" 或 "create_checkout"
}
原則很簡單:識別碼可以是內部的(你的資料庫 UUID),但不應包含 PII(email、電話、全名)。我們會在安全章節再談。
錯誤欄位
錯誤是另一個重點。理想情況下,至少拆成類型、代碼與文字:
interface ErrorFields {
error_type?: "validation" | "upstream" | "timeout" | "system";
error_code?: string; // HTTP 狀態、資料庫代碼或自定 enum
error_message?: string; // 簡短且安全
stack?: string; // 堆疊;注意大小與 PII
}
重要的是,error_message 不應包含敏感資料(例如「failed for card 4111 1111 1111 1111」)。最好像 "payment provider declined card" 並附上安全的代碼。
完整日誌介面
把一切組合起來:
export interface LogEvent
extends BaseLogFields,
CorrelationFields,
BusinessFields,
ErrorFields {
// 保留擴充欄位空間
[key: string]: unknown;
}
這樣的介面你可以同時用在 MCP 伺服器、commerce 後端與代理上。所有服務都用同一格式寫日誌,關聯就會從苦差事變成輕鬆散步。
4. GiftGenius 的最簡 JSON 記錄器(MCP 伺服器)
先從極簡開始。假設你的 MCP 伺服器是一個 Node.js/TypeScript 應用。我們做個 logger 工具:
// mcp/logging.ts
import { LogEvent, LogLevel } from "./types";
function log(level: LogLevel, event: Omit<LogEvent, "level" | "timestamp">) {
const enriched: LogEvent = {
timestamp: new Date().toISOString(),
level,
env: process.env.NODE_ENV === "production" ? "prod" : "dev",
...event,
};
// 將 JSON 輸出到 stdout——之後由日誌系統收集
console.log(JSON.stringify(enriched));
}
export const logger = {
debug: (event: Omit<LogEvent, "level" | "timestamp">) =>
log("debug", event),
info: (event: Omit<LogEvent, "level" | "timestamp">) =>
log("info", event),
warn: (event: Omit<LogEvent, "level" | "timestamp">) =>
log("warn", event),
error: (event: Omit<LogEvent, "level" | "timestamp">) =>
log("error", event),
};
這不是 Pino 或 Winston,但對本課來說重要的是理念:所有內容都以 JSON 和標準欄位輸出。
現在把它用在 MCP 工具處理器 suggest_gifts。
5. MCP 工具的記錄:從進到出
假設你已有工具 suggest_gifts 的處理器,接收使用者偏好並回傳 SKU 清單。我們把日誌加上去。
假設我們預先從 HTTP 標頭 x-trace-id 取出了 trace_id(要如何放入,會在下一節的關聯部分說明)。
// mcp/tools/suggestGifts.ts
import { logger } from "../logging";
export async function suggestGiftsTool(args: SuggestGiftsArgs, ctx: {
traceId: string;
userId?: string;
}) {
logger.info({
message: "suggest_gifts called",
service: "mcp",
trace_id: ctx.traceId,
user_id: ctx.userId,
tool_name: "suggest_gifts",
flow: "gift_recommendation",
step: "fetch_candidates",
});
try {
const gifts = await fetchGiftsFromCatalog(args);
logger.info({
message: "suggest_gifts succeeded",
service: "mcp",
trace_id: ctx.traceId,
user_id: ctx.userId,
tool_name: "suggest_gifts",
flow: "gift_recommendation",
step: "rank_candidates",
result_count: gifts.length,
});
return gifts;
} catch (error: any) {
logger.error({
message: "suggest_gifts failed",
service: "mcp",
trace_id: ctx.traceId,
user_id: ctx.userId,
tool_name: "suggest_gifts",
flow: "gift_recommendation",
step: "fetch_candidates",
error_type: "upstream",
error_message: error.message,
});
throw error;
}
}
現在只要有一個 trace_id,你就能看到:
- 工具是否被呼叫;
- 找到多少候選;
- 在哪個步驟失敗。
同時不會出現 email 或使用者姓名——只有內部的 user_id。
6. trace_id 在 ChatGPT App 中從哪裡誕生
我們來看看 trace_id 應該在哪裡誕生。重要的是:它不是綁定在單一請求上。trace_id 是業務操作的識別碼。因此需要區分兩種常見情境:
「窄型」 MCP 工具
也就是工具做一個小而快的操作,立刻回傳結果(沒有互動式 UI):
- get_gifts_for_budget
- calculate_price
- save_lead 等。
在這種情況下可以這樣看:一次 MCP 工具呼叫 = 一個業務請求 = 一個 trace。 統一的 trace_id 會在 MCP gateway / MCP 伺服器端於進入 tool-call 時誕生(或若你使用 OpenTelemetry,則取用已存在的追蹤脈絡)。接著這個 trace_id 會用在所有內部呼叫(REST 服務、資料庫、佇列),並作為 trace_id 寫入日誌。
ChatGPT 與 Apps SDK 不介入其中:它們只是送出 JSON-RPC 的 tool-call,而追蹤從你可控的區域開始。
「寬型」 MCP 工具(回傳小工具)
此時工具不會把業務操作完全做完,而是啟動互動情境:回傳小工具,之後它在沙箱裡會做許多 fetch() 請求(載入禮物清單、篩選、checkout 等)。
在這個情境中,端到端追蹤的運作方式不同:
- 主要的業務操作存在於小工具對後端的 HTTP 請求之中;
- 因此每一次重要的從小工具發往後端的 fetch(),都會拿到自己的 trace_id,這個 ID 會在後端/gateway(該 fetch 的第一個伺服器 hop)誕生。
不論是 ChatGPT 還是小工具本身,都不是 trace_id 的「真實來源」:它們只能在請求中傳遞一些輔助識別碼(session_id、widget_id、user_id),而 trace_id 的建立與管理發生在伺服器端。
「窄型」 MCP tool:一個 trace 對應一個 tool-call
我們看看沒有小工具的「窄型」工具流程:
sequenceDiagram
participant ChatGPT as ChatGPT / Agent
participant MCP as MCP Server
participant GiftAPI as Gift API
participant Pricing as Pricing API
ChatGPT->>MCP: JSON-RPC tools.call get_gifts
MCP->>MCP: start trace (trace_id = T-123)
MCP->>GiftAPI: GET /gifts (x-trace-id = T-123)
GiftAPI-->>MCP: 200 OK (trace_id = T-123)
MCP->>Pricing: GET /price (x-trace-id = T-123)
Pricing-->>MCP: 200 OK (trace_id = T-123)
MCP-->>ChatGPT: tool result(可選地附上 trace_id)
模式:
- 在 tool-call 進入 MCP 時建立 trace(或從 traceparent/x-trace-id 取用既有的);
- 該 tool-call 的後續路徑(呼叫服務、資料庫、快取)都用同一個 trace_id;
- 日誌中不會出現小工具,因為這裡沒有小工具。
這種做法帶來:
- 清楚的一次操作「快照」:「MCP 工具 suggest_gifts → Gift API → Pricing API → 回應」;
- 一個 trace_id 對應一個工具呼叫。
「寬型」 MCP tool:小工具與多個 trace
現在來看 GiftGenius 的情境:MCP 工具回傳小工具:
- ChatGPT 呼叫 MCP 工具,例如 open_gift_widget。
- MCP 工具組裝小工具描述(layout、初始狀態)並回傳。
- 小工具掛載在沙箱並開始「活著」:
- GET /api/gifts?budget=50&page=1
- GET /api/gifts?budget=50&filter=for_developers
- POST /api/checkout
- POST /api/save-lead
- 每一個這樣的 HTTP 請求都會進到你的 Next.js 後端/gateway——在那裡你建立新的 trace:
fetch #1 -> trace_id = T-501 (載入第一頁禮物)
fetch #2 -> trace_id = T-502 (套用「給開發者」篩選器)
fetch #3 -> trace_id = T-503 (建立 checkout)
...
也就是:
- MCP 工具是「寬型」:它的主要任務是打開小工具,而不是完成整條業務鏈;
- 真正的業務邏輯(禮物清單、選出最佳禮物、checkout)在後端,它處理小工具的 fetch();
- 由一組 fetch() 請求組成的同一業務情境,會有獨特的 trace_id,你會在伺服器接收到 HTTP 請求時產生它。
你也可以在每個 trace 中傳遞:
- session_id(若有 ChatGPT 會話 ID),
- widget_id,
- user_id,
- tool_run_id 或任何其他情境。
用 trace_id 你檢視單次操作(例如「checkout #3」),用 session_id/widget_id 你檢視同一小工具/會話中的所有活動。
7. 請求關聯:trace_id 如何穿越 App、MCP、小工具與後端
來到最有趣的部分:如何讓必要的識別碼通過所有層:ChatGPT、MCP 伺服器、小工具、commerce 後端與 webhook。
帶著 trace_id 的請求流程(「寬型」情境示意)
以下是 GiftGenius 的簡圖:
sequenceDiagram
participant ChatGPT as ChatGPT UI
participant MCP as MCP Server
participant Widget as GiftGenius Widget
participant Backend as Next.js Backend
participant ACP as Commerce API
participant WH as Webhook Handler
ChatGPT->>MCP: tools.call open_gift_widget
MCP-->>ChatGPT: Widget description (layout, config)
ChatGPT->>Widget: 在沙箱中渲染小工具
Widget->>Backend: GET /api/gifts (trace_id = T-501,誕生於 Backend)
Backend->>ACP: GET /gifts (x-trace-id = T-501)
ACP-->>Backend: 200 OK (trace_id = T-501)
Backend-->>Widget: 含禮物的 JSON(日誌中包含 trace_id = T-501)
Widget->>Backend: POST /api/checkout (trace_id = T-503,誕生於 Backend)
Backend->>ACP: POST /checkout (x-trace-id = T-503)
ACP-->>Backend: 200 OK (trace_id = T-503)
ACP-->>WH: webhook order.created (x-trace-id = T-503)
WH->>WH: 記錄事件(trace_id = T-503)
請注意:
- 在這個示意中,trace_id 不是由小工具產生;
- 它出現在你的後端入口(Next.js route handler、API gateway 等);
- 接著這個 trace_id 會被傳遞到:
- 後端日誌,
- 呼叫 ACP 時的 x-trace-id 標頭,
- 以及(若 ACP 會回傳/往後傳)webhook。
6.5. 在後端為來自小工具的呼叫產生並傳遞 trace_id
我們把範例改得更明確一些:trace_id 在後端誕生,而不是在小工具中。
// app/api/mcp/tools/call/route.ts (Next.js backend, proxy 到 MCP)
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { logger } from "@/mcp/logging";
export async function POST(req: NextRequest) {
// 若從外部世界(例如 gateway)帶來 trace_id,則沿用;
// 否則在進入後端時生成新的。
const incomingTraceId = req.headers.get("x-trace-id");
const traceId = incomingTraceId ?? uuidv4();
const requestId = uuidv4();
logger.info({
message: "mcp.tools.call received from widget",
service: "backend",
trace_id: traceId,
request_id: requestId,
});
const body = await req.json();
const res = await fetch(process.env.MCP_SERVER_URL!, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-trace-id": traceId,
},
body: JSON.stringify(body),
});
const json = await res.json();
logger.info({
message: "mcp.tools.call completed",
service: "backend",
trace_id: traceId,
request_id: requestId,
});
return NextResponse.json(json);
}
在 MCP 伺服器端,我們只需讀取這個標頭並在自己的日誌中使用 trace_id(就像第 5 節的範例)。
小工具甚至可以完全不知道 trace_id 的存在——只要呼叫 /api/mcp/tools/call 即可。不過如果你想讓 UI 的動作也能對應到追蹤,你可以在回應中回傳 trace_id,並以 service: "app-widget" 的形式寫入你自己的 JSON 日誌(客戶端或透過 SaaS 分析)。
從小工具呼叫 MCP 的前端範例
// app/lib/mcpClient.ts (小工具)
export async function callMcpTool(toolName: string, args: unknown) {
const res = await fetch("/api/mcp/tools/call", {
method: "POST",
headers: {
"Content-Type": "application/json",
// 這裡不產生 trace_id——它會在後端誕生
},
body: JSON.stringify({ toolName, args }),
});
// 若後端在回應中回傳 trace_id,可以把它保存起來:
const data = await res.json();
return data;
}
如果你願意,也可以擴充後端處理器,讓它把 trace_id 加入 JSON 回應,這樣小工具就能:
- 記錄事件,例如 "service": "app-widget", "trace_id": "...";
- 為開發者顯示 trace 連結。
但原則依舊:來源 trace_id 的是伺服器,而不是小工具。
把 trace_id 繼續傳遞到 ACP/commerce
現在在 MCP 工具 create_checkout_session 裡呼叫你的 commerce API,並繼續透過標頭傳遞 trace_id:
// mcp/tools/createCheckout.ts
import { logger } from "../logging";
export async function createCheckoutTool(
args: CreateCheckoutArgs,
ctx: { traceId: string; userId?: string }
) {
logger.info({
message: "create_checkout called",
service: "mcp",
trace_id: ctx.traceId,
user_id: ctx.userId,
tool_name: "create_checkout_session",
flow: "checkout",
step: "create_session",
});
const res = await fetch(process.env.COMMERCE_URL + "/checkout", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-trace-id": ctx.traceId,
},
body: JSON.stringify({
userId: ctx.userId,
...args,
}),
});
if (!res.ok) {
logger.error({
message: "checkout API failed",
service: "mcp",
trace_id: ctx.traceId,
user_id: ctx.userId,
flow: "checkout",
step: "create_session",
error_type: "upstream",
error_code: String(res.status),
});
throw new Error("Checkout API failed");
}
const data = await res.json();
logger.info({
message: "checkout session created",
service: "mcp",
trace_id: ctx.traceId,
user_id: ctx.userId,
flow: "checkout",
step: "create_session",
checkout_session_id: data.sessionId,
});
return data;
}
Commerce 後端同樣會讀取 x-trace-id 並寫入它的 JSON 日誌。如此一來,只靠一個 trace_id 你就能看到:
- 小工具對後端的入站 HTTP 請求(trace 在此誕生);
- 轉發 MCP(若有);
- 內部呼叫 create_checkout_session;
- 對 commerce API 的請求;
- commerce 後端的回應;
- 以及(若它也傳遞標頭)webhook order.created。
8. 日誌等級:在 LLM 應用中的 DEBUG、INFO、WARN、ERROR
日誌等級能幫助你避免資訊淹沒。在 ChatGPT App 中,建議這樣理解:
- DEBUG —— 詳細的技術資訊,適合 dev/staging。例如,簡化後的提示、代理的中間狀態、外部 API 的「原始」回應(不含 PII)。在生產環境要非常謹慎。
- INFO —— 正常的業務事件:「suggest_gifts succeeded, 10 candidates」、「checkout session created」、「webhook order.created processed」。這些日誌在生產環境可保持啟用。
- WARN —— 發生了非典型情況,但系統仍續行。例如:「fallback to cached catalog because upstream timeout」、「model returned invalid tool args, retry with different schema」。
- ERROR —— 明確的失敗:情境未如預期完成。例如:「checkout API failed」、「failed to persist order」、「tool crashed with unhandled exception」。
為方便起見,可加個簡單 helper,避免手動判斷:
type LogLevel = "debug" | "info" | "warn" | "error";
function isProd() {
return process.env.NODE_ENV === "production";
}
export function shouldLogLevel(level: LogLevel): boolean {
if (isProd()) {
return level === "info" || level === "warn" || level === "error";
}
return true; // 在開發環境啟用全部
}
然後只有在 shouldLogLevel("debug") 回傳 true 時才呼叫 logger.debug。
在生產環境記錄完整提示與模型回應的 DEBUG 日誌尤其危險:很容易混入密碼、金鑰、或使用者不小心貼在聊天中的任何 PII。
9. 日誌安全:PII 清洗與祕密
日誌很容易過猶不及。如果你「什麼都寫」,你會:
- 違反資料保護法規;
- 讓攻擊者生活更輕鬆(祕密與 token 可以直接從日誌撈走);
- 連你自己都不敢讓他人存取日誌系統。
因此有個簡單原則:日誌要足以理解發生了什麼,但不足以竊取資料。
好的實務:
- 記錄 user_id,而不是 email 或電話。若確實需要在日誌中使用 email 來除錯,請記錄其雜湊或遮罩("a***@gmail.com")。
- 別在日誌中寫完整 token("sk-...")、refresh token、client_secret、密碼。若非記不可——只露前/後 4 碼與類型(例如「sk-***1234」)。
- 小心 tool_input 與 tool_output。它們可能包含使用者寫的任何東西。在生產環境不要完整記錄,或至少:
- 僅記錄已通過驗證的型別化欄位;
- 截斷到合理大小並套用清洗——以正則遮罩(email、信用卡號等)。
最簡化的清洗器範例:
export function sanitize(text: string): string {
return text
.replace(/sk-[a-zA-Z0-9]{20,}/g, "sk-***redacted***")
.replace(/\b\d{16}\b/g, "****-****-****-****"); // 信用卡號
}
在記錄使用者輸入時:
logger.debug({
message: "raw_user_message",
service: "app-widget",
trace_id,
user_id,
raw: sanitize(userMessage),
});
這段程式離工業級還很遠,但足以說明想法:先清洗,再記錄。
10. 實作:GiftGenius 的 gift_recommended 事件
現在做個練習:設計當 GiftGenius 最終為使用者選定「最佳禮物」時要寫入的 gift_recommended 日誌事件。
此事件應能回答:
- 是哪位使用者(內部 ID);
- 是哪個禮物(SKU);
- 依哪個情境、在哪個步驟;
- 對應的 trace_id,以便與其他日誌關聯。
同時它不該包含 PII 與祕密。
範例:
{
"timestamp": "2025-11-21T10:22:33.456Z",
"level": "info",
"service": "agent",
"env": "prod",
"message": "gift_recommended",
"trace_id": "a3b9e8c2-1f47-4ec5-9bdf-9d4e0c123abc",
"agent_run_id": "run_7f1d2c",
"user_id": "u_123456",
"flow": "gift_recommendation",
"step": "final_choice",
"recommended_sku": "SKU-SPACE-MUG-001",
"price_cents": 2499,
"currency": "USD",
"reason_summary": "recipient_likes_space_and_practical_gadgets"
}
重點:
- 我們記錄 user_id,但不是 email 或姓名;
- SKU 與價格是正常的業務資料,不算 PII;
- reason_summary 是簡短的技術標籤,而不是使用者的完整句子;
- 有 trace_id 與 agent_run_id,可以回溯代理在做出選擇前呼叫了哪些工具。
反之,不該記錄:
- 模型的完整回應文字與「人類說明」;
- 使用者的提示(「我要送給同事小明的禮物,他的電話是……,地址是……」);
- 任何支付資料。
11. 日誌範例:成功的 tool-call 與 ACP 錯誤
為了加深印象,來看兩個小型 JSON 範例。
成功的 tools.call(在 MCP)
{
"timestamp": "2025-11-21T10:20:00.000Z",
"level": "info",
"service": "mcp",
"env": "prod",
"message": "tools.call completed",
"trace_id": "a3b9e8c2-1f47-4ec5-9bdf-9d4e0c123abc",
"request_id": "req_01JCQ5CZ0YQ6TM7E5W8H3N3F2Y",
"tool_name": "suggest_gifts",
"user_id": "u_123456",
"flow": "gift_recommendation",
"step": "rank_candidates",
"result_count": 12,
"latency_ms": 430
}
從這一條日誌就能看出:
- 是哪個工具;
- 針對哪位使用者;
- 在哪個情境;
- 花了多久、回傳了多少候選。
透過 trace_id 你可以輕鬆找到與同一請求相關的 UI 與代理日誌。
ACP/checkout 錯誤
{
"timestamp": "2025-11-21T10:21:05.789Z",
"level": "error",
"service": "commerce",
"env": "prod",
"message": "checkout failed",
"trace_id": "a3b9e8c2-1f47-4ec5-9bdf-9d4e0c123abc",
"checkout_session_id": "cs_test_9YpQvJH8",
"user_id": "u_123456",
"flow": "checkout",
"step": "charge_customer",
"error_type": "upstream",
"error_code": "PAYMENT_DECLINED",
"error_message": "payment provider declined card",
"provider": "stripe",
"amount_cents": 2499,
"currency": "USD"
}
同樣地,沒有卡號,只有錯誤代碼與安全的訊息。並且依舊是相同的 trace_id,因此你可以把這條日誌與 gift_recommended 關聯起來,理解鏈路在哪個步驟斷掉。
12. 如何避免讓日誌變成垃圾
很容易心癢難耐:「既然能優雅地記錄,那就什麼都記吧」。結果很快就會得到海量 JSON 噪音,重要事件被淹沒。
幾個實用建議:
- 重複的「我進入了函式 X」這類日誌沒有太大價值。更好的做法是記錄重要事件:情境開始/結束、外部 API 呼叫、workflow 的步驟轉換、錯誤。
- 對高頻操作(例如商品目錄查詢)可啟用抽樣:完整記錄每 N 次中的 1 次,其餘僅在錯誤時記錄。
- 在生產環境關閉 DEBUG(或強烈限縮)。若一定要記錄提示/回應,也請有限度並做清洗。
關於度量與 SLO 我們會在下一堂課中單獨討論,但現在就要理解:日誌不只是「用來除錯」,它是整個 ChatGPT 堆疊可觀測性的基石。
還記得一開始那位產品經理提到的「空清單」與會掛掉的 checkout 嗎?用這套日誌方案,你可以在幾分鐘內找到指定 trace_id 的所有請求,查看 suggest_gifts(工具回傳了多少候選、在哪個步驟失敗)與 "checkout failed" 的日誌,以及支付方回傳的 error_code。這不再是「在日誌稀飯裡辦案」,而是「從請求到 webhook」的清晰劇情。
總之,對 ChatGPT App 而言,好的日誌堆疊並不是「我們把東西寫到 stdout」而已,而是:
- 正確的 trace_id 誕生位置(對「窄型」工具而言在 MCP gateway/伺服器;對「寬型」情境則在小工具 fetch() 的後端入口);
- 每一個有意義的業務呼叫都能用統一的 trace_id 貫穿 App → MCP → commerce → webhooks;
- 共用的 JSON 日誌結構(service、env、user_id、flow、step、tool_name 等);
- 正確處理 PII 與祕密(清洗、遮罩、生產環境限縮 DEBUG);
- 合理的等級與無噪音。
有了這個基礎,其他可觀測性工具(度量、SLO、警報)會更有用,幫助你不只是「收集日誌」,而是實際管理 ChatGPT App 的品質與穩定性。
13. 使用結構化日誌與關聯時的常見錯誤
錯誤 №1:缺少跨所有服務的統一 trace_id。
典型情況:MCP gateway 產生一個 ID、commerce 後端又產生另一個,webhook 完全不知道關聯,而小工具的日誌裡根本沒有 trace_id。結果關聯只能靠人工比對「看起來時間差不多」。正確做法是在可控的入口產生 trace_id(「窄型」工具用 MCP 伺服器;小工具的 fetch() 用後端/gateway),並穿越所有邊界傳遞它:HTTP 標頭、JSON 欄位、代理的上下文。
錯誤 №2:嘗試在小工具裡產生 trace_id 並把它當作「真相」。
有時會覺得直覺:「就讓 React 小工具用 crypto.randomUUID(),然後掛在標頭上」。問題是這會把 trace_id 放在客戶端,它可能與實際的伺服器追蹤(OpenTelemetry、gateway、其他服務)不一致。更可靠的方式是讓 trace_id 出現在你能完整控制伺服器路徑的地方:Next.js 後端、API gateway 或 MCP 伺服器。小工具頂多讀取與記錄這個 ID。
錯誤 №3:為了「方便除錯」而記錄 PII 與祕密。
在開發初期,「把整個提示、token、卡號與 email 都寫進日誌」看起來很方便。幾個月後它會變成未爆彈:對日誌的存取變得敏感,安全稽核會提出難堪的問題,你甚至不敢截圖。從一開始就做清洗,不要記錄那些明天會被你緊張刪除的東西。
錯誤 №4:某一層仍然使用無結構的字串日誌。
有時團隊會在 MCP 與 commerce 做漂亮的 JSON 日誌,但在小工具端留下 console.log("step 1", data)。結果鏈路的頭尾仍是斷開的。
錯誤 №5:濫用 ERROR 等級。
若每個微小偏差(例如「模型回傳 0 個候選,因此顯示 fallback」)都記成 ERROR,生產警報會一直亮,團隊很快就不理會警報。請誠實區分:「WARN——奇怪但我們應對了;ERROR——使用者情境真的壞了」。
錯誤 №6:服務之間的日誌結構不一致。
當某個服務把欄位叫 traceId、另一個叫 correlation_id、第三個叫 requestId,再好的日誌系統也救不了。務必就統一 schema(如我們用的 LogEvent)達成共識,並在所有元件中遵循:App 小工具、MCP 伺服器、代理、ACP、webhook。這樣建立端到端儀表板與調查事故才是分鐘等級,而非天數。
錯誤 №7:為了「最佳化」日誌大小而丟掉關鍵欄位。
有時為了省空間,有人會想:「把 user_id 或 flow 拿掉吧,反正是小事」。之後當你需要回答「哪些使用者最常遇到 checkout 失敗?」時,才發現資料不在。若要取捨,請先丟掉長篇的文字 payload(請求/回應本體)與除錯欄位,而不是識別碼與關鍵情境屬性。
GO TO FULL VERSION