1. 为什么在 ChatGPT App 中需要结构化日志
设想产品经理给你发来消息:“用户抱怨说选礼物时有时显示空列表,有时 checkout 会崩。能修到明天的 demo 吗?”你手里有:
- ChatGPT,有时会调用你的 App,有时不会。
- 在沙盒中的小部件。
- MCP 服务器,会调用外部商品库与 ACP。
- 来自支付的 webhooks。
而你只有一些零散的文本日志,比如 MCP 某处的 “something went wrong” 和后端某处的 “order failed”。在并发请求下这会变成一团乱麻:完全无法判断哪个日志对应哪个用户、哪个请求。
这正是结构化 JSON 日志和统一 trace_id 的价值所在,它们能让你:
- 通过一个标识符看到完整链路:从 ChatGPT 请求到 webhook "order.created";
- 按服务、工具、用户、场景过滤日志;
- 快速回答“为什么 checkout 会失败”和“Agent 在开始幻觉之前做了什么”。
目标很简单:让生产环境的 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)日志 |
|---|---|---|
| 解析 | 手动,靠正则 | 按字段自动解析 |
| 按字段搜索 | 复杂的正则查询 | 简单表达式 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; // (可选)父操作的 ID
request_id?: string; // 本地 HTTP 请求或 tool-call 的 ID
agent_run_id?: string; // Agent 运行的 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 状态、数据库代码或你的枚举
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 后端和 Agent 中统一使用。各服务都按一个格式写日志,关联就会从“闯关游戏”变成“轻松散步”。
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. 在 ChatGPT App 中 trace_id 从何而来
我们来厘清 trace_id 应该在哪里产生。要理解,它并不绑定到一个具体的请求。trace_id 是一个业务操作的标识。因此需要区分两种常见情况:
“狭义”的 MCP 工具
即工具做完一项小而快的操作就立即返回结果(无交互式 UI):
- get_gifts_for_budget
- calculate_price
- save_lead 等等
这种情况下,约定为:一次 MCP 工具调用 = 一次业务请求 = 一个 trace。 端到端的 trace_id在MCP 网关 / MCP 服务器侧在进入 tool-call 时生成(或若使用 OpenTelemetry 则沿用已有追踪上下文)。之后该 trace_id 将用于所有内部调用(REST 服务、数据库、队列),并作为 trace_id 字段进入日志。
ChatGPT 与 Apps SDK 并不参与其中:它们只发送 JSON-RPC 的 tool-call;追踪由你在可控区域内启动。
“宽泛”的 MCP 工具(返回小部件)
这类工具并不一次性完成业务操作,而是启动一个交互式场景:返回一个小部件,该小部件在沙盒中会发起大量 fetch() 请求(加载礼物列表、筛选、checkout 等)。
在这种场景中,端到端追踪的组织方式不同:
- 主要的业务操作都发生在小部件对后端的 HTTP 请求里;
- 因此来自小部件到你后端的每一个重要 fetch() 请求,都应获得自己的 trace_id,该 ID 在后端/网关上生成(该 fetch 的第一个服务端 hop)。
ChatGPT 和小部件本身都不是 trace_id 的“真相来源”:它们最多在请求中传递一些辅助标识(session_id、widget_id、user_id),而 trace_id 的创建与管理发生在服务器端。
“狭义” MCP 工具:每次 tool-call 一个 trace
看看没有小部件的“狭义”工具的流程:
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 工具:小部件与多个 trace
现在看看 GiftGenius 的场景,MCP 工具返回小部件:
- ChatGPT 调用 MCP 工具,例如 open_gift_widget。
- MCP 工具生成小部件的描述(布局、初始状态)并返回。
- 小部件挂载在沙盒中,开始它的生命周期:
- 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 后端/网关——在那里你创建新的 trace:
fetch #1 -> trace_id = T-501 (加载礼物第一页)
fetch #2 -> trace_id = T-502 (应用“开发者向”筛选)
fetch #3 -> trace_id = T-503 (创建 checkout)
...
也就是说:
- MCP 工具是“宽泛”的:其主要任务是打开小部件,而非完成全业务链;
- 真正的业务逻辑(礼物列表、选择 Top 礼物、checkout)在后端里,由小部件的 fetch() 请求触发;
- 一组由同一业务场景串联起来的 fetch() 请求,各自拥有唯一的 trace_id,你在 HTTP 请求进入服务器端时生成。
你还可以把以下信息透传到每个 trace:
- session_id(ChatGPT 会话 ID,如有),
- widget_id,
- user_id,
- tool_run_id 或其它上下文。
按 trace_id 可查看具体操作(“第 3 次 checkout”);按 session_id/widget_id 可查看一个小部件/会话中的所有活动。
7. 请求关联:trace_id 如何贯穿 App、MCP、小部件与后端
来到最有意思的部分:如何让所需的标识符穿过所有层——ChatGPT、MCP 服务器、小部件、commerce 后端和 webhooks。
带 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 路由处理器、API 网关等);
- 之后这个 trace_id 会被继续传递:
- 写入后端日志,
- 在调用 ACP 时放入 x-trace-id 头,
- 若 ACP 返回/继续透传,也进入 webhooks。
6.5. 在后端为来自小部件的调用生成与传递 trace_id
我们用一个更显式的例子:trace_id 是在后端生成的,而不是在小部件里。
// app/api/mcp/tools/call/route.ts (Next.js backend, 代理到 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,并在小部件的 JSON 日志中写 service: "app-widget"(客户端或通过 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 继续传到 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 后端的响应;
- 以及(若继续透传)order.created 的 webhook。
8. 日志级别:在 LLM 应用中使用 DEBUG、INFO、WARN、ERROR
日志级别可以帮助你不被信息淹没。在 ChatGPT App 中可以这样理解:
- DEBUG——详细的技术信息,用于 dev/staging。比如缩短的提示词、中间状态、外部 API 的“原始”响应(不含 PII)。在生产环境中要非常谨慎。
- INFO——正常的业务事件:“suggest_gifts 成功,10 个候选”、“checkout session created”、“webhook order.created processed”。这类日志可在生产环境保留开启。
- WARN——出现了非常规情况,但系统继续工作。例如:“因上游超时回退到缓存目录”、“模型返回了无效工具参数,尝试不同 schema 重试”。
- ERROR——明显失败:场景未按预期完成。例如:“checkout API failed”、“failed to persist order”、“tool crashed with unhandled exception”。
为方便起见,可以加个简单的助手,避免手写字符串:
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; // 在 dev 中全开
}
只在 shouldLogLevel("debug") 返回 true 时才调用 logger.debug。
尤其要注意,千万别在生产中写入包含完整提示词与模型回答的 DEBUG 日志:其中很容易出现密码、密钥,或用户误贴到聊天中的任何 PII。
9. 日志安全:PII 清理与秘密信息
记录日志很容易过度。一旦“什么都写”,你就会:
- 违反数据保护法规;
- 给攻击者送福利(秘密和令牌能直接从日志捞走);
- 自己都不敢给别人开放日志系统的访问。
因此遵循简单原则:日志里信息足以定位问题,但不足以窃取数据。
推荐实践:
- 记录 user_id,而不是 email 或电话。若为调试确需 email,可记录其哈希或做遮罩("a***@gmail.com")。
- 切勿把完整令牌("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 事件
现在做个练习:设计 gift_recommended 日志事件——当 GiftGenius 最终为用户选定“Top 礼物”时记录。
该事件应能回答:
- 哪个用户(内部 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,以便回溯 Agent 在这个选择过程中调用了哪些工具。
确定不该记录的是:
- 模型完整的回答文本(“人话”解释);
- 用户提示词(“想给同事买礼物,她的手机是……,地址是……”);
- 任何支付数据。
11. 日志示例:成功的 tool-call 与 ACP 错误
来两个小的 JSON 示例做巩固。
成功的 MCP tools.call
{
"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 与 Agent 日志。
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. 如何避免把日志写成垃圾
“既然我们能把日志写得那么漂亮,那就把一切都记录下来吧”的诱惑很大。这样你很快就会得到 TB 级的 JSON 噪音,淹没掉真正有用的事件。
一些实用建议:
- 像“我进入了函数 X”这类重复日志没有太大价值。更应记录有意义的事件:场景开始/结束、外部 API 调用、工作流步骤变更、错误等。
- 对高频操作(如商品目录查询)可以加抽样:完整记录每 N 次中的 1 次,其余仅在出错时记录。
- 在生产中保持 DEBUG 关闭(或极其有选择地开启)。若必须记录提示词/回答——也应受限并做清洗。
关于指标与 SLO 我们会在下一讲单独讨论。但现在就要明白:日志不仅“用于调试”,它是整个 ChatGPT 栈可观测性的基石。
还记得开头那位抱怨“空列表”和 checkout 崩溃的产品经理吗?按本文的日志方案,你可以在几分钟内找到相应 trace_id 的所有请求,查看 suggest_gifts(工具返回了多少候选、在哪一步失败)以及含 error_code 的 "checkout failed"(来自支付)。这不再是“在日志粥里破案”,而是“从请求到 webhook”的清晰故事。
最终,一个良好的 ChatGPT App 日志栈,并不是“我们随便往 stdout 打点东西”,而是:
- trace_id 在正确的地方生成(“狭义”工具在 MCP 网关/服务器;“宽泛”场景的小部件 fetch() 在后端入口);
- 对每一个有意义的业务调用,trace_id 能在 App → MCP → commerce → webhooks 中端到端贯穿;
- 统一的 JSON 日志 schema(service、env、user_id、flow、step、tool_name 等);
- 对 PII 与秘密信息谨慎处理(清洗、掩码、生产环境限制 DEBUG);
- 合理使用日志级别,避免噪音。
有了这套基础,其它可观测工具(指标、SLO、告警)会更有用,帮助你不止“收集日志”,而是真正管理 ChatGPT App 的质量与稳定性。
13. 使用结构化日志与关联时的常见错误
错误 1:缺少贯穿所有服务的统一 trace_id。
经典场景:MCP 网关生成一个 ID,commerce 后端又生成一个,webhooks 根本不知道关联,而小部件日志中没有 trace_id。结果关联变成“看着时间差不多就当是它”。正确做法是在可控的入口生成 trace_id(“狭义”工具在 MCP 服务器,“宽泛”小部件的 fetch() 在后端/网关),并跨越边界透传:HTTP 头、JSON 字段、Agent 上下文。
错误 2:试图在小部件中生成 trace_id 并视其为“真相”。
有时看起来很合理:“我们就在 React 小部件里调用 crypto.randomUUID(),然后挂到请求头上”。问题是这样 trace_id 会“活在客户端”,且不一定与真正的服务端追踪(OpenTelemetry、网关、其它服务)一致。更可靠的方式是让 trace_id 出现在你能掌控完整服务端路径的地方:Next.js 后端、API 网关或 MCP 服务器。小部件如需可读取并记录该 ID,但不应自封为“真相”。
错误 3:为“调试方便”记录 PII 与秘密信息。
开发初期“特别方便”的做法是把提示词、令牌、卡号、email 整段写入日志。几个月后这会变成定时炸弹:日志访问变得“有毒”,安全审计提出尖锐问题,你自己也不敢截图。请从一开始就做清洗,不要记录未来需要仓促清除的东西。
错误 4:在某一层仍然使用无结构的字符串日志。
有的团队在 MCP 与 commerce 里做了很棒的 JSON 日志,但在小部件里仍然写 console.log("step 1", data)。结果链路的起点与终点仍是断开的。
错误 5:滥用 ERROR 级别。
如果任何微小偏离(比如“模型返回 0 个候选,展示 fallback”)都按 ERROR 记录,生产告警会一直“燃烧”。团队很快就对告警麻木。请诚实地区分:“WARN——有点怪,但我们兜住了;ERROR——用户场景确实坏了”。
错误 6:各服务的日志 schema 不一致。
当一个服务叫 traceId,另一个叫 correlation_id,第三个叫 requestId,再强的日志系统也救不了。务必约定统一的 schema(就像我们定义的 LogEvent),并在 App 小部件、MCP 服务器、Agent、ACP、webhooks 等各组件中坚持使用。这样构建端到端仪表盘与事故排查才会从“天”为单位降到“分”。
错误 7:为“优化”日志体积而删掉关键字段。
有人为了省空间提出:“把 user_id 或 flow 去掉吧,反正不重要”。然后突然要回答“哪些用户的 checkout 最常失败?”——却发现无从得知。若要取舍,请优先删除冗长的文本负载(请求/响应体)和调试字段,而不是标识符与关键上下文字段。
GO TO FULL VERSION