1. なぜ ChatGPT App に構造化ログが必要なのか
プロダクト担当がこう言ってきたと想像してください。「ユーザーがギフトを選ぶと、たまにリストが空で表示されるし、たまに checkout が落ちる。明日のデモまでに直せる?」 いま手元にあるのは次の通りです。
- ChatGPT(あなたの App を呼ぶときもあれば、呼ばないときもある)。
- サンドボックス内のウィジェット。
- 外部の商品データベースや ACP を叩く MCP サーバー。
- 決済プロバイダからの webhook。
そして手がかりは、MCP のどこかにある “something went wrong”、backend のどこかにある “order failed” といった断片的なテキストログだけ。並行リクエストが走ると一気にカオス化し、どのログがどのユーザー/どのリクエストに対応しているのか分からなくなります。
そこで役立つのが、構造化 JSON ログと統一 trace_id です。目的は次の通りです。
- 単一の識別子でチェーン全体を辿る(ChatGPT のリクエストから webhook "order.created" まで)。
- サービス、ツール、ユーザー、シナリオでログをフィルタリング。
- 「なぜ checkout が落ちたのか」「エージェントは幻覚を始める直前に何をしていたのか」に素早く答える。
つまりゴールは単純です。プロダクションの GiftGenius を、一般的なマイクロサービスアプリ並みにデバッグ・監視できるようにすることです。
2. 文字列ログ vs 構造化ログ:なぜ console.log("おっと") ではもう足りないのか
通常の Next.js 開発では、文字列ログで済ませることがよくあります。人間が読める文と、たまに値を2〜3個吐く程度。単体サービスならまだ耐えられます。しかし ChatGPT App スタックでは、そうしたログはすぐにスパゲッティ化します。
テキストログは、ファイルやコンソールの1行にすぎません。例:
console.error(`Error in suggestGifts for user ${userId}: ${error.message}`);
この手のメッセージが10万件あると、「昨日の checkout で userId=… に関する MCP のエラー全部」を探すのはもう簡単ではありません。ツールのエラーで自動ダッシュボードを作るのはなおさら困難です。
構造化ログは JSON オブジェクトで、メッセージ本文に加え、レベル、時刻、サービス、ID 群、技術/ビジネス文脈といったフィールドを持ちます。先ほどの例の構造化版はこうなります。
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 |
| 文脈の付加 | メッセージ文字列に埋め込み | スキーマを壊さず新規フィールドで拡張 |
| リクエスト相関 | 並行リクエストではほぼ不可能 | 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; // エージェント実行の ID(あれば)
tool_call_id?: string; // 特定ツール呼び出しの ID
checkout_session_id?: string; // ACP/決済セッションの ID
}
trace_id が主役です。「ユーザーがギフトを要望 → 我々が候補を出す → 注文を作成 → webhook を受け取る」という一連の流れに属する全ログで、同一の trace_id を用います。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"
}
基本原則は単純です。ID は内部のもので構いません(DB の UUID など)が、PII(email、電話番号、氏名)は含めないようにします。セキュリティの節で改めて触れます。
エラーフィールド
エラーは別枠で考えます。典型的なエラーログは、少なくともタイプ、コード、メッセージに分けたくなります。
interface ErrorFields {
error_type?: "validation" | "upstream" | "timeout" | "system";
error_code?: string; // HTTP ステータス、DB コード、または独自 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 backend、エージェントのいずれでも使えます。全サービスが同じ形式でログを書けば、相関は苦行ではなく快適な散歩になります。
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 はビジネスオペレーションの識別子です。ここでは、よくある 2 つの状況を分けて考えます。
「狭い」 MCP ツール
ツールが小さな操作を実行してすぐ結果を返す(対話 UI なし)場合です。
- get_gifts_for_budget
- calculate_price
- save_lead など
この場合の考え方はシンプルです。MCP ツール1回の呼び出し = 1 ビジネスリクエスト = 1 トレース。 エンドツーエンドの trace_id は、MCP ゲートウェイ/MCP サーバー側 で tool-call 受信時に生成(または OpenTelemetry を使っているなら既存のトレースコンテキストを引き継ぎ)します。その後の内部呼び出し(REST サービス、DB、キュー)でもこの trace_id を使い、ログにフィールドとして書きます。
ChatGPT と Apps SDK はここには介入しません。単に JSON-RPC の tool-call を送るだけで、トレーシングはあなたの制御下で始まります。
「広い」 MCP ツール(ウィジェットを返す)
この場合、ツールはビジネスオペレーションを最後まで完遂せず、対話的なシーンを開始します。つまりウィジェットを返し、そのウィジェットがサンドボックス内で多数の fetch() を実行します(ギフト一覧の取得、フィルタ、checkout など)。
このシナリオでは、エンドツーエンドのトレーシングは次のようになります。
- 主要なビジネス操作は、ウィジェットから backend への HTTP リクエスト 内で行われる。
- したがって、ウィジェットから backend への意味のある各 fetch() は独自の trace_id を持ち、これは backend/gateway(その fetch の最初のサーバーホップ)で生成される。
ChatGPT もウィジェットも trace_id の「唯一の真実のソース」ではありません。彼らは session_id, widget_id, user_id のような補助 ID をリクエストに載せられますが、trace_id の生成と管理はサーバー側で行います。
「狭い」 MCP ツール:tool-call あたり 1 トレース
ウィジェットなしの「狭い」ツールにおけるフローは次の通りです。
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 を返す)
パターン:
- MCP に tool-call が入った時点でトレースを開始(または traceparent/x-trace-id を引き継ぐ)。
- その tool-call の以降の経路(外部サービス、DB、キャッシュ)を同一の trace_id でロギング。
- ウィジェットは存在しないため、ログには登場しない。
このアプローチにより:
- 単一操作の「スナップショット」: 「MCP ツール suggest_gifts → Gift API → Pricing API → 応答」。
- ツール呼び出し 1 回につき trace_id は 1 つ。
「広い」 MCP ツール:ウィジェットと複数のトレース
次に、MCP ツールがウィジェットを返す GiftGenius のシナリオです。
- ChatGPT が MCP ツール(例:open_gift_widget)を呼ぶ。
- MCP ツールはウィジェット記述(レイアウト、初期状態)を組み立てて返す。
- ウィジェットはサンドボックスにマウントされ、以下のような多数の fetch() を行う:
- 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 backend/gateway に到達し、そこで新たなトレースが作成される:
fetch #1 -> trace_id = T-501 (ギフトの1ページ目を読み込む)
fetch #2 -> trace_id = T-502 (「for_developers」フィルタを適用)
fetch #3 -> trace_id = T-503 (checkout を作成)
...
要点:
- この MCP ツールは「広い」。主な役割はウィジェットを開くことで、ビジネスチェーンを完遂することではない。
- 実際のビジネスロジック(ギフト一覧、トップギフト選定、checkout)はbackend に存在し、ウィジェットの fetch() を処理する。
- 同一シナリオに属する fetch() 群は、それぞれがサーバーが生成した固有の trace_id を持つ。
加えて、各トレースに次のような情報を持たせるとよいでしょう。
- session_id(ChatGPT セッション ID があるなら)
- widget_id
- user_id
- tool_run_id その他の文脈
trace_id は個々の操作(「checkout #3」など)を追うのに使用し、session_id / widget_id では単一のウィジェット/セッション全体を俯瞰します。
7. 相関:trace_id を App、MCP、ウィジェット、backend の間でどう渡すか
いよいよ本題です。必要な識別子を ChatGPT、MCP サーバー、ウィジェット、commerce backend、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 はウィジェットで生成しない。
- backend(Next.js の route handler、API-gateway 等)への HTTP リクエストの入口で生成される。
- 以降、この trace_id を次のように伝播させる:
- backend のログ。
- ACP 呼び出し時のヘッダー x-trace-id。
- ACP が返す/その先に伝える webhook。
6.5. ウィジェット発の呼び出しに対し、backend で trace_id を生成・伝播
trace_id はウィジェットではなく backend で生まれることを、コードで明確に示しましょう。
// 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 が来ていればそれを使い、
// なければ backend 入口で新規生成する。
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 を使用します。
ウィジェットは trace_id の存在を知らなくても構いません。/api/mcp/tools/call を呼ぶだけで十分です。もし UI のアクションをトレースに紐付けて表示/ロギングしたいなら、レスポンスに trace_id を含め、クライアント側の JSON ログ(SaaS 分析など経由)で "service" を "app-widget" にして書いても良いでしょう。
ウィジェットから 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 を生成しない — backend で生成される
},
body: JSON.stringify({ toolName, args }),
});
// backend が trace_id を本文で返すなら、保存できる
const data = await res.json();
return data;
}
必要であれば backend ハンドラを拡張し、JSON レスポンスに trace_id を含めましょう。そうすればウィジェットは次のことができます。
- "service": "app-widget", "trace_id": "..." の形でイベントをロギング。
- 開発者向けにトレースリンクを表示。
しかし原則は不変です。ソースとなるのはウィジェットではなくサーバーです。
ACP/commerce へ trace_id を引き継ぐ
続いて、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 backend 側でも x-trace-id を読み取り、同じ値を自分の JSON ログに書きます。すると単一の trace_id により、次の全体像を見通せます。
- ウィジェットから backend へ到達した最初の HTTP リクエスト(ここでトレースが誕生)。
- MCP へのプロキシ(存在する場合)。
- 内部の create_checkout_session 呼び出し。
- commerce API へのリクエスト。
- commerce backend の応答。
- さらにヘッダーが継承されるなら、webhook order.created。
8. ログレベル:LLM アプリ文脈での DEBUG, INFO, WARN, ERROR
ログレベルは情報過多を防ぎます。ChatGPT App では次のように解釈すると便利です。
- DEBUG — 開発/ステージングで有用な詳細な技術情報。短縮したプロンプト、エージェントの中間状態、外部 API の「生」レスポンス(PII 抜き)など。本番では慎重に扱うべきです。
- INFO — 正常なビジネスイベント。「suggest_gifts succeeded, 10 candidates」「checkout session created」「webhook order.created processed」など。本番でも有効にして構いません。
- WARN — 想定外だが動作継続。「upstream timeout によりキャッシュ済みカタログへフォールバック」「モデルが不正な tool args を返したのでスキーマ変更でリトライ」など。
- ERROR — 明確な失敗。シナリオが正しく完了していない。「checkout API failed」「failed to persist order」「未処理例外でツールがクラッシュ」など。
毎回文字列でレベル判定を書くのを避けるため、簡単なヘルパーを用意してもよいでしょう。
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; // 開発では全て出す
}
そして logger.debug は、shouldLogLevel("debug") が true のときだけ呼ぶようにします。
特に本番で危険なのは、プロンプトやモデルの回答全文を含む DEBUG ログです。ユーザーが誤って貼り付けたパスワードや鍵、PII が容易に混ざり得ます。
9. ログの安全性:PII スクラブとシークレット
ログはやりすぎが簡単です。何でもかんでも書けば、次の事態を招きます。
- データ保護法に抵触。
- 攻撃者の助けになる(シークレットやトークンがログから抜け取れる)。
- ログシステムへのアクセス付与を自分たちが怖がるようになる。
原則は単純です。「何が起きたか理解するには十分だが、データを盗むには不十分」な情報だけを残すこと。
推奨プラクティス:
- user_id をログに出し、email や電話番号は出さない。どうしても email が必要ならハッシュ化やマスキング("a***@gmail.com")を行う。
- フルのトークン("sk-...")、refresh トークン、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 ログイベントを設計しましょう。
このイベントで次の問いに答えられるようにします。
- どのユーザーか(内部 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 のエラー
理解を定着させるため、2 つの 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
}
この 1 本のログから既に次が分かります。
- どのツールか。
- どのユーザーか。
- どのシナリオか。
- 処理時間と候補数。
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 の呼び出し、ワークフローのステップ遷移、エラーなど「意味のあるイベント」をログしましょう。
- 頻出操作(例:商品カタログの取得)はサンプリングを導入。N 回に 1 回だけ詳細をログし、それ以外はエラー時のみログする。
- 本番では DEBUG をオフ(または厳しく限定)。プロンプト/レスポンスのロギングが必要なら、限定的かつスクラブ適用で。
メトリクスと SLO は次の講義で扱いますが、今の時点でも理解しておくべきなのは、ログは「デバッグ用」だけではなく、ChatGPT スタック全体の観測性の土台だということです。
冒頭の「リストが空」「checkout が落ちる」というプロダクト担当の話を思い出してください。本講義のログ設計があれば、狙った trace_id のリクエスト群を数分で見つけ、suggest_gifts のログ(候補数やどのステップで失敗したか)と、決済側の "checkout failed" ログ(error_code つき)を照合できます。もはや「ログのカオス」を漁るのではなく、「リクエストから webhook まで」の明快なシナリオを追えるのです。
結論として、ChatGPT App の健全なロギング基盤とは「stdout に何かを書いている」ではなく、次を満たすものです。
- trace_id を生成する正しい場所(「狭い」ツールなら MCP ゲートウェイ/サーバー、「広い」シナリオのウィジェット fetch() なら backend の入口)。
- 各ビジネス呼び出しで App → MCP → commerce → webhooks を貫く統一の trace_id。
- 共通の JSON ログスキーマ(service, env, user_id, flow, step, tool_name など)。
- PII とシークレットの慎重な取り扱い(スクラブ、マスキング、本番での限定的 DEBUG)。
- 意味のあるレベル設定とノイズの抑制。
この基盤があれば、観測ツール(メトリクス、SLO、アラート)は格段に有用になり、単なる「ログ収集」を超えて、ChatGPT App の品質と安定性を実際にマネージできるようになります。
13. 構造化ログと相関で陥りがちなミス
ミス 1:全サービスで統一の trace_id がない。
典型例:MCP ゲートウェイは一つの ID、commerce backend は別の ID、webhook は相関を全く知らない。ウィジェットのログにも trace_id がない。結果として、相関は「時間が近いからこれっぽい」という手作業探索になる。正解は、制御下の入口(「狭い」ツールは MCP サーバー、「広い」シナリオの fetch() は backend/gateway)で trace_id を生成し、HTTP ヘッダー、JSON フィールド、エージェントのコンテキストなど、あらゆる境界を跨いで持ち回ること。
ミス 2:ウィジェットで trace_id を生成して「真実」とみなす。
React ウィジェットで crypto.randomUUID() を作ってヘッダーに付けたくなるかもしれない。しかしその場合、trace_id はクライアント由来となり、サーバー側の実トレーシング(OpenTelemetry、gateway、他サービス)と一致しない恐れがある。より堅牢なのは、trace_id が Next.js backend/API-gateway/MCP サーバーといった「サーバーの制御点」で生まれること。ウィジェットは必要なら読み取り・ロギングするだけに留める。
ミス 3:デバッグの「便宜」で PII やシークレットをログに出す。
開発初期は、プロンプト本文、トークン、カード番号、email を丸ごと記録するのが「とても便利」に思える。数カ月後、それは時限爆弾となる。ログ閲覧が有害になり、セキュリティ監査で厄介な指摘を受け、スクリーンショットを見せるのも怖くなる。最初からスクラブを導入し、明日慌てて消す羽目になるデータは書かない。
ミス 4:どこかの層だけ文字列ログのまま。
MCP と commerce では JSON が整っていても、ウィジェットでは console.log("step 1", data) のままというケース。これではチェーンの端が断絶する。
ミス 5:ERROR レベルの乱用。
些細な逸脱(「モデルが候補 0 件、fallback を表示」)まで ERROR にすると、本番のアラートが常時点灯し、誰も反応しなくなる。WARN=変だが復帰、ERROR=ユーザーシナリオが本当に破綻 を誠実に分けること。
ミス 6:サービス間でログスキーマが不一致。
あるサービスでは traceId、別では correlation_id、さらに別では requestId と名称がバラバラでは、どんなログ基盤でも救えない。LogEvent のような統一スキーマに合意し、App ウィジェット、MCP サーバー、エージェント、ACP、webhook を含む全コンポーネントで遵守すること。そうすれば、エンドツーエンドのダッシュボードやインシデント調査は分単位で終わる。
ミス 7:ログサイズ最適化の名目で重要フィールドを捨てる。
コスト削減のために「user_id や flow は消そう、些末だし」と誰かが言い出すことがある。ところが「どのユーザーで checkout がよく落ちるか?」に答えられなくなる。削るなら、長大なテキスト payload(リクエスト/レスポンスの本文)やデバッグ用フィールドであって、ID やキーとなる文脈属性ではない。
GO TO FULL VERSION