CodeGym /Các khóa học /ChatGPT Apps /Log có cấu trúc và tương quan yêu cầu

Log có cấu trúc và tương quan yêu cầu

ChatGPT Apps
Mức độ , Bài học
Có sẵn

1. Vì sao bạn cần log có cấu trúc trong ChatGPT App

Hãy tưởng tượng PM viết cho bạn: “Người dùng phàn nàn rằng khi chọn quà đôi khi danh sách trống, và đôi khi checkout bị lỗi. Có thể sửa kịp demo ngày mai không?”. Bạn có:

  • ChatGPT, thỉnh thoảng gọi App của bạn, thỉnh thoảng — không.
  • Widget trong sandbox.
  • Máy chủ MCP gọi CSDL hàng hóa bên ngoài và ACP.
  • Webhook từ cổng thanh toán.

Và chỉ có những log văn bản rời rạc kiểu “something went wrong” đâu đó ở MCP và “order failed” đâu đó ở backend. Khi có các yêu cầu song song, tất cả trở thành hỗn loạn: không thể hiểu log nào thuộc về người dùng nào và yêu cầu nào.

Log JSON có cấu trúc và một trace_id thống nhất chính là để:

  • chỉ với một mã nhận diện có thể thấy toàn bộ chuỗi: từ yêu cầu ChatGPT đến webhook "order.created";
  • lọc log theo dịch vụ, tool, người dùng, kịch bản;
  • trả lời nhanh các câu hỏi “vì sao checkout bị lỗi” và “agent đã làm gì trước khi bắt đầu ‘ảo giác’”.

Tức là mục tiêu rất rõ: khiến GiftGenius chạy production có thể debug và theo dõi không thua gì một ứng dụng microservice thông thường.

2. Log chuỗi so với log có cấu trúc: vì sao console.log("ôi") không còn hiệu quả

Trong phát triển Next.js thông thường, nhiều người chỉ dừng ở log chuỗi: in ra một câu dễ đọc và đôi khi vài giá trị. Với một dịch vụ đơn lẻ thì còn tạm chấp nhận. Nhưng trong stack ChatGPT App, kiểu log này rất nhanh biến thành “cháo”.

Log văn bản — chỉ là một dòng trong file hoặc console. Ví dụ:


console.error(`Error in suggestGifts for user ${userId}: ${error.message}`);

Khi có hàng trăm nghìn thông điệp kiểu này, việc tìm “tất cả lỗi MCP trong checkout với userId=… của hôm qua” đã là không dễ. Và việc tự động dựng dashboard theo lỗi của tool — gần như bất khả.

Log có cấu trúc — là một đối tượng JSON, ngoài phần nội dung message còn có tập các trường: level, thời gian, service, các mã nhận diện, ngữ cảnh kỹ thuật và nghiệp vụ. Tương tự ví dụ trước:

logger.error({
    message: "suggest_gifts failed",
    user_id: userId,
    trace_id,
    service: "mcp",
    tool_name: "suggest_gifts",
    error_message: error.message,
});

Mỗi trường được hệ thống log (ELK, Loki, Better Stack, Datadog, v.v.) lập chỉ mục, và sau đó bạn có thể viết truy vấn như service="mcp" AND level="error" AND tool_name="suggest_gifts" hoặc chỉ cần tìm theo trace_id="...".

Cho trực quan — một bảng nhỏ.

So sánh gì Log chuỗi Log có cấu trúc (JSON)
Parsing Thủ công, qua regex Tự động theo trường
Tìm theo trường Truy vấn regexp phức tạp Biểu thức đơn giản field=value
Tổng hợp và dashboard Khó, nhiều “vá víu” Đơn giản: count() , group by field
Bổ sung ngữ cảnh Bằng văn bản trong message Thêm trường mới mà không đổi schema
Tương quan yêu cầu Gần như bất khả khi có song song Tìm bình thường theo trace_id/request_id

Trong thế giới ứng dụng LLM, nơi một nửa vấn đề không phải là “lỗi 500” mà là “model gọi nhầm tool”, nếu không có log có cấu trúc thì bạn thực sự “mù”.

3. Giải phẫu log JSON cho ChatGPT App

Tiếp theo, ta thống nhất một “chuẩn tối thiểu” cho bản ghi log mà bạn sẽ dùng ở mọi lớp của GiftGenius. Nó không hoàn hảo, nhưng bao phủ 80% nhu cầu.

Chia các trường log thành vài nhóm.

Trường kỹ thuật

Các trường kỹ thuật giúp công cụ quan sát hiểu được bản ghi đến từ đâu.

Có thể mô tả kiểu TypeScript như sau:

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;      // Mô tả ngắn gọn sự kiện
}

timestamp nên ghi theo ISO UTC ("2025-11-21T10:15:30.123Z"), khi đó các dịch vụ khác nhau có thể sắp xếp theo thời gian mà không phải “múa” với time zone. serviceenv giúp tách, ví dụ, log của MCP production khỏi log của widget ở dev. Điều này đặc biệt hữu ích nếu sau này bạn muốn chơi cùng OpenTelemetry và dùng các quy ước chung như service.name, service.version, v.v.

Trường tương quan

Đây là phần quan trọng nhất. Không có chúng bạn sẽ không thể liên kết các sự kiện với nhau.

Thêm vào interface của chúng ta:

interface CorrelationFields {
    trace_id: string;        // ID xuyên suốt của toàn bộ kịch bản
    span_id?: string;        // (tùy chọn) ID của thao tác cụ thể
    parent_span_id?: string; // (tùy chọn) thao tác cha
    request_id?: string;     // ID cục bộ của HTTP request hoặc tool-call
    agent_run_id?: string;   // ID lần chạy agent (nếu có)
    tool_call_id?: string;   // ID lần gọi tool cụ thể
    checkout_session_id?: string; // ID phiên ACP/thanh toán
}

trace_id — “nhân vật chính”. Nó phải giống nhau trong tất cả log liên quan đến kịch bản “Người dùng yêu cầu gợi ý quà, ta gợi ý, tạo đơn, nhận webhook”. span_idparent_span_id cho phép dựng “cây thao tác” theo kiểu distributed tracing, nhưng khi bắt đầu bạn có thể chỉ cần trace_idrequest_id.

Ngữ cảnh nghiệp vụ

Log kỹ thuật mà thiếu ngữ cảnh nghiệp vụ sẽ biến thành “có gì đó xảy ra, ở đâu đó, lúc nào đó”. Ta cần hiểu người dùng nào và ở bước nào của kịch bản bị ảnh hưởng.

Mở rộng interface:

interface BusinessFields {
    user_id?: string;     // ID ẩn danh, KHÔNG phải email
    tenant_id?: string;   // Tổ chức/tài khoản nếu B2B
    flow?: string;        // Ví dụ, "gift_recommendation" hoặc "checkout"
    step?: string;        // Ví dụ, "collect_requirements" hoặc "create_checkout"
}

Nguyên tắc rất đơn giản: các mã nhận diện có thể là nội bộ (UUID từ DB của bạn), nhưng không được chứa PII (email, điện thoại, họ tên). Ta sẽ nói thêm ở phần bảo mật.

Trường lỗi

Lỗi — là “bài ca” riêng. Một log lỗi điển hình nên tách ít nhất thành loại, mã và văn bản:

interface ErrorFields {
    error_type?: "validation" | "upstream" | "timeout" | "system";
    error_code?: string;       // Trạng thái HTTP, mã DB hoặc enum riêng
    error_message?: string;    // Ngắn gọn và an toàn
    stack?: string;            // Stack, chú ý kích thước và PII
}

Quan trọng là error_message không chứa dữ liệu nhạy cảm (như “failed for card 4111 1111 1111 1111”). Tốt hơn là "payment provider declined card" và kèm một mã an toàn.

Interface log đầy đủ

Gộp tất cả lại:

export interface LogEvent
    extends BaseLogFields,
        CorrelationFields,
        BusinessFields,
        ErrorFields {
    // để dành chỗ cho các trường bổ sung
    [key: string]: unknown;
}

Interface như vậy bạn có thể dùng ở MCP server, commerce backend và agent. Khi đó mọi dịch vụ sẽ ghi log theo cùng một định dạng, và việc tương quan trở thành “đi dạo”, không còn là “thử thách”.

4. Logger JSON tối giản cho GiftGenius (MCP server)

Bắt đầu từ thứ gì đó tối giản. Giả sử MCP server của bạn là ứng dụng Node.js/TypeScript. Tạo tiện ích 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,
    };

    // Ghi JSON ra stdout — hệ thống log sẽ thu thập tiếp
    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),
};

Đây không phải Pino hay Winston, nhưng với khóa học này, điều quan trọng là ý tưởng: mọi thứ được ghi ở dạng JSON với các trường tử tế.

Giờ dùng nó trong handler của MCP tool suggest_gifts.

5. Ghi log MCP tool: từ vào đến ra

Giả sử bạn đã có handler cho tool suggest_gifts, nhận sở thích người dùng và trả về danh sách SKU. Hãy thêm log vào đó.

Giả sử ta đã lấy sẵn trace_id từ header HTTP x-trace-id (cách đưa nó vào — sẽ bàn trong phần tiếp theo về tương quan).

// 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;
  }
}

Giờ chỉ với một trace_id bạn sẽ thấy:

  • tool thực sự đã được gọi;
  • tìm được bao nhiêu ứng viên;
  • nó lỗi ở bước nào.

Đồng thời không nơi nào xuất hiện email hay tên người dùng — chỉ có user_id nội bộ.

6. trace_id được “sinh ra” ở đâu trong ChatGPT App

Hãy xem trace_id nên được sinh ở đâu. Quan trọng: nó không gắn với một request cụ thể. trace_id — là mã nhận diện của chính giao dịch/bước nghiệp vụ. Vì vậy cần tách hai tình huống điển hình:

MCP‑tool “hẹp”

Khi tool thực hiện một thao tác nhỏ gọn và trả kết quả ngay (không có UI tương tác):

  • get_gifts_for_budget
  • calculate_price
  • save_lead, v.v.

Trường hợp này tiện nhất là: một lần gọi MCP tool = một yêu cầu nghiệp vụ = một trace. trace_id xuyên suốt được sinh ở phía MCP‑gateway / MCP‑server khi tool‑call vào (hoặc lấy từ ngữ cảnh tracing sẵn có nếu bạn dùng OpenTelemetry). Sau đó trace_id này được dùng trong mọi lời gọi nội bộ (REST service, DB, queue) và xuất hiện trong log dưới trường trace_id.

ChatGPT và Apps SDK không can dự: chúng chỉ gửi JSON‑RPC tool‑call, còn tracing bắt đầu ở khu vực bạn kiểm soát.

MCP‑tool “rộng” (trả về widget)

Ở đây tool không hoàn tất giao dịch nghiệp vụ đến cùng, mà khởi động một cảnh tương tác: trả về widget, widget trong sandbox sẽ thực hiện hàng chục lệnh fetch() (tải danh sách quà, bộ lọc, checkout, v.v.).

Trong kịch bản này, tracing xuyên suốt khác đi:

  • các thao tác nghiệp vụ chính sống trong các yêu cầu HTTP từ widget đến backend;
  • vì vậy mỗi lệnh fetch() quan trọng từ widget đến backend của bạn nhận trace_id riêng, được sinh ra ở backend/gateway (hop server đầu tiên cho lệnh fetch đó).

Cả ChatGPT lẫn widget không phải là “nguồn chân lý” cho trace_id: chúng chỉ có thể truyền một số mã nhận diện phụ (session_id, widget_id, user_id), còn việc tạo và quản lý trace_id diễn ra ở server.

MCP‑tool “hẹp”: một trace cho một tool‑call

Luồng cho tool “hẹp” không có widget trông như sau:

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 (tùy chọn kèm trace_id)

Mẫu hình:

  • khi tool‑call vào MCP bạn tạo trace (hoặc lấy từ traceparent/x-trace-id);
  • toàn bộ đường đi tiếp theo của tool‑call (gọi service, DB, cache) được ghi log với cùng một trace_id;
  • log không có sự tham gia của widget, vì ở đây không có widget.

Cách này cho bạn:

  • một “ảnh chụp” rõ ràng của một thao tác: “MCP‑tool suggest_gifts → Gift API → Pricing API → trả lời”;
  • một trace_id cho một lần gọi tool.

MCP‑tool “rộng”: widget và nhiều trace

Giờ đến kịch bản GiftGenius, nơi MCP‑tool trả về widget:

  1. ChatGPT gọi MCP‑tool, ví dụ open_gift_widget.
  2. MCP‑tool tạo mô tả widget (layout, trạng thái khởi tạo) và trả về.
  3. Widget được mount trong sandbox và bắt đầu “sống”:
    • GET /api/gifts?budget=50&page=1
    • GET /api/gifts?budget=50&filter=for_developers
    • POST /api/checkout
    • POST /api/save-lead
  4. Mỗi yêu cầu HTTP như vậy đến Next.js backend/gateway của bạn — và tại đó bạn tạo một trace mới:
fetch #1  -> trace_id = T-501  (tải trang quà tặng đầu tiên)
fetch #2  -> trace_id = T-502  (áp dụng bộ lọc “for_developers”)
fetch #3  -> trace_id = T-503  (tạo checkout)
...

Tức là:

  • MCP‑tool “rộng”: nhiệm vụ chính là mở widget, không phải thực hiện cả chuỗi nghiệp vụ;
  • logic nghiệp vụ thực sự (danh sách quà, chọn quà top, checkout) sống ở backend, nơi xử lý các lệnh fetch() của widget;
  • nhóm lệnh fetch() gộp trong một kịch bản nghiệp vụ có trace_id riêng, do bạn sinh ra trên server khi HTTP request “đi vào”.

Bổ sung bạn có thể truyền vào mỗi trace:

  • session_id (ID phiên ChatGPT, nếu có),
  • widget_id,
  • user_id,
  • tool_run_id hoặc ngữ cảnh khác.

Theo trace_id bạn xem thao tác cụ thể (“checkout #3”), theo session_id / widget_id — mọi thứ diễn ra trong cùng một widget/phiên.

7. Tương quan yêu cầu: trace_id đi qua App, MCP, widget và backend như thế nào

Đến phần thú vị nhất: làm sao để những mã nhận diện cần thiết đi qua mọi lớp: ChatGPT, MCP server, widget, backend commerce và webhook.

Luồng yêu cầu với trace_id (sơ đồ cho trường hợp “rộng”)

Sơ đồ nhỏ cho 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: Render widget trong sandbox

    Widget->>Backend: GET /api/gifts (trace_id = T-501, sinh ra ở Backend)
    Backend->>ACP: GET /gifts (x-trace-id = T-501)
    ACP-->>Backend: 200 OK (trace_id = T-501)
    Backend-->>Widget: JSON chứa danh sách quà (trace_id = T-501 trong log)

    Widget->>Backend: POST /api/checkout (trace_id = T-503, sinh ra ở 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: Ghi log sự kiện (trace_id = T-503)

Lưu ý:

  • trong sơ đồ này trace_id không được sinh ra bởi widget;
  • nó xuất hiện tại điểm vào của HTTP request đến backend của bạn (Next.js route handler, API gateway, v.v.);
  • sau đó trace_id được truyền:
    • vào log của backend,
    • vào header x-trace-id khi gọi ACP,
    • vào webhook, nếu ACP trả về/truyền tiếp nó.

6.5. Tạo và truyền trace_id ở backend cho các lệnh gọi từ widget

Viết lại ví dụ để thấy rõ: trace_id được sinh ở backend, không phải ở widget.

// app/api/mcp/tools/call/route.ts (Next.js backend, proxy đến MCP)
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { logger } from "@/mcp/logging";

export async function POST(req: NextRequest) {
  // Nếu có trace_id đến từ bên ngoài (ví dụ từ gateway) — dùng nó.
  // Nếu không — tạo mới ngay khi vào 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);
}

Ở phía MCP server ta chỉ cần đọc header này và dùng trace_id trong log của mình (như ví dụ ở phần 5).

Widget thậm chí không cần biết về trace_id — chỉ cần gọi /api/mcp/tools/call. Nhưng nếu bạn muốn hiển thị hoặc ghi log hành vi UI gắn với tracing, có thể trả trace_id trong phản hồi và ghi, ví dụ, service: "app-widget" vào log JSON (client hoặc qua SaaS analytics).

Ví dụ gọi MCP từ widget phía client

// app/lib/mcpClient.ts (widget)
export async function callMcpTool(toolName: string, args: unknown) {
  const res = await fetch("/api/mcp/tools/call", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      // KHÔNG tạo trace_id ở đây — nó sẽ được sinh ở backend
    },
    body: JSON.stringify({ toolName, args }),
  });

  // Nếu backend trả về trace_id trong body, có thể lưu lại:
  const data = await res.json();
  return data;
}

Nếu muốn, bạn có thể mở rộng handler backend để thêm trace_id vào JSON phản hồi, khi đó widget có thể:

  • ghi log sự kiện dạng "service": "app-widget", "trace_id": "...",
  • hiển thị liên kết trace cho developer.

Nhưng nguyên tắc vẫn vậy: nguồn gốc trace_id — là server, không phải widget.

Truyền tiếp trace_id sang ACP/commerce

Bây giờ bên trong MCP tool create_checkout_session ta gọi commerce API của bạn và vẫn “mang theo” trace_id trong header:

// 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, đến lượt mình, cũng đọc x-trace-id và ghi nó vào log JSON. Khi đó với một trace_id bạn sẽ thấy:

  • HTTP request vào từ widget ở backend (nơi trace được sinh ra);
  • proxy sang MCP (nếu có);
  • lời gọi nội bộ create_checkout_session;
  • yêu cầu đến commerce API;
  • phản hồi của commerce backend;
  • và nếu nó cũng truyền header, webhook order.created.

8. Mức log: DEBUG, INFO, WARN, ERROR trong bối cảnh ứng dụng LLM

Mức log giúp bạn không “chìm” trong thông tin. Trong ChatGPT App có thể hiểu như sau:

  • DEBUG — thông tin kỹ thuật chi tiết, hữu ích ở dev/staging. Ví dụ, prompt rút gọn, trạng thái trung gian của agent, phản hồi “thô” từ API bên ngoài (không có PII). Ở production cần cực kỳ cẩn trọng.
  • INFO — sự kiện nghiệp vụ bình thường: “suggest_gifts succeeded, 10 candidates”, “checkout session created”, “webhook order.created processed”. Có thể để bật ở prod.
  • WARN — có gì đó bất thường, nhưng hệ thống vẫn chạy tiếp. Ví dụ: “fallback to cached catalog because upstream timeout”, “model returned invalid tool args, retry with different schema”.
  • ERROR — thất bại rõ ràng: kịch bản không kết thúc như mong muốn. Ví dụ: “checkout API failed”, “failed to persist order”, “tool crashed with unhandled exception”.

Để tiện, có thể thêm helper nhỏ để khỏi phải “đánh máy” thủ công:

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; // trong dev bật tất cả
}

Và chỉ gọi logger.debug khi shouldLogLevel("debug") trả về true.

Đặc biệt nguy hiểm ở production khi ghi log DEBUG với toàn bộ prompt và phản hồi của model: rất dễ chứa mật khẩu, khóa, hoặc bất kỳ PII nào mà người dùng lỡ dán vào chat.

9. Bảo mật log: PII‑scrub và bí mật

Với log rất dễ “quá tay”. Nếu ghi “tất cả mọi thứ”, bạn sẽ:

  • vi phạm luật bảo vệ dữ liệu;
  • tạo điều kiện cho kẻ xấu (bí mật và token có thể bị lôi ra từ log);
  • tự khiến mình e ngại khi cấp quyền truy cập hệ thống log.

Vì vậy nguyên tắc là: log đủ để hiểu chuyện gì xảy ra, nhưng không đủ để đánh cắp dữ liệu.

Thực hành tốt:

  1. Ghi user_id, không phải email hay số điện thoại. Nếu thực sự cần email trong log để debug, hãy ghi hash hoặc che bớt ("a***@gmail.com").
  2. Không bao giờ ghi đầy đủ token ("sk-..."), refresh token, client_secret, mật khẩu. Nếu rất cần — chỉ hiển thị 4 ký tự đầu/cuối và kiểu (“sk-***1234”).
  3. Cẩn thận với tool_inputtool_output. Có thể chứa mọi thứ người dùng nhập. Ở production hoặc là đừng log toàn bộ, hoặc:
    • chỉ log các trường đã được kiểu hóa và xác thực;
    • cắt ngắn hợp lý và áp dụng scrub — che theo regex (email, số thẻ, v.v.).

Ví dụ sanitizer đơn giản (rất tối giản):

export function sanitize(text: string): string {
  return text
    .replace(/sk-[a-zA-Z0-9]{20,}/g, "sk-***redacted***")
    .replace(/\b\d{16}\b/g, "****-****-****-****"); // thẻ
}

Và khi ghi log input của người dùng:

logger.debug({
  message: "raw_user_message",
  service: "app-widget",
  trace_id,
  user_id,
  raw: sanitize(userMessage),
});

Đoạn code này còn xa mức “sản xuất”, nhưng cho thấy ý tưởng: làm sạch trước, rồi mới log.

10. Thực hành: sự kiện gift_recommended cho GiftGenius

Giờ làm bài tập: thiết kế log sự kiện gift_recommended, ghi lại khi GiftGenius chọn “quà top” cho người dùng.

Sự kiện phải cho phép trả lời:

  • người dùng nào (ID nội bộ);
  • món quà nào (SKU);
  • theo kịch bản nào và ở bước nào;
  • trace_id nào để liên kết với log khác.

Và đồng thời không chứa PII và bí mật.

Ví dụ:

{
  "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"
}

Điểm quan trọng:

  • Ta log user_id, không phải email hay tên;
  • SKU và giá — dữ liệu nghiệp vụ bình thường, không phải PII;
  • reason_summary — tag kỹ thuật ngắn gọn, không phải câu “đầy đủ” của người dùng;
  • trace_idagent_run_id để xem agent đã gọi tool nào trên đường đến quyết định này.

Và những thứ chắc chắn không nên log:

  • văn bản trả lời của model đầy đủ với giải thích “dễ đọc”;
  • prompt của người dùng (“muốn quà cho đồng nghiệp, ...” kèm thông tin nhạy cảm);
  • bất kỳ dữ liệu thanh toán nào.

11. Ví dụ log: tool‑call thành công và lỗi ACP

Để củng cố — hai ví dụ JSON nhỏ.

tools.call thành công ở 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
}

Từ một log như vậy đã thấy:

  • tool nào;
  • cho người dùng nào;
  • theo kịch bản nào;
  • mất bao lâu và trả về bao nhiêu ứng viên.

Theo trace_id bạn dễ dàng tìm log UI và agent liên quan đến cùng yêu cầu.

Lỗi 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"
}

Một lần nữa, không có số thẻ, chỉ có mã lỗi và thông báo an toàn. Và vẫn là cùng trace_id, vì vậy bạn có thể liên kết log này với gift_recommended và hiểu chuỗi bị “gãy” ở đâu.

12. Làm sao để log không thành “rác”

Rất dễ “cám dỗ”: “vì chúng ta log đẹp rồi, hãy log mọi thứ”. Bạn sẽ nhanh chóng có gigabyte JSON “ồn ào”, nơi sự kiện hữu ích bị chìm.

Vài gợi ý thực tế:

  • Log trùng lặp kiểu “tôi đi vào hàm X” mà không có thông tin bổ sung thì ít hữu dụng. Tốt hơn là log sự kiện quan trọng: bắt đầu/kết thúc kịch bản, gọi API bên ngoài, chuyển bước workflow, lỗi.
  • Với thao tác thường xuyên (ví dụ, truy vấn catalog), bật sampling: log đầy đủ 1 trong N yêu cầu, còn lại — chỉ khi lỗi.
  • Ở production hãy tắt DEBUG (hoặc chọn lọc rất kỹ). Nếu cần log prompt/response — hãy giới hạn và có scrub.

Về metric và SLO chúng ta sẽ nói ở bài sau, nhưng ngay bây giờ cần hiểu: log không chỉ “để debug”, mà là nền tảng quan sát cho toàn bộ stack ChatGPT.

Nhớ PM ở đầu bài với “danh sách trống” và checkout bị lỗi? Với sơ đồ log này bạn sẽ tìm được tất cả yêu cầu có trace_id cần thiết trong vài phút, xem suggest_gifts (trả về bao nhiêu ứng viên, lỗi ở bước nào) và log "checkout failed" với error_code từ cổng thanh toán. Không còn là “điều tra cháo log”, mà là kịch bản rõ ràng “từ yêu cầu đến webhook”.

Cuối cùng, một stack log tốt cho ChatGPT App không phải “chúng tôi ghi gì đó vào stdout”, mà là:

  • điểm sinh trace_id đúng chỗ (ở MCP‑gateway/server cho tool “hẹp” và ở điểm vào backend cho các lệnh fetch() của widget trong kịch bản “rộng”);
  • trace_id thống nhất xuyên suốt App → MCP → commerce → webhook cho mỗi lời gọi nghiệp vụ có ý nghĩa;
  • schema JSON log chung (service, env, user_id, flow, step, tool_name, v.v.);
  • xử lý PII và bí mật cẩn thận (scrub, che, giới hạn DEBUG ở production);
  • mức log có ý nghĩa và không “ồn ào”.

Với nền tảng như vậy, các công cụ quan sát khác (metric, SLO, alert) sẽ hữu ích hơn nhiều và giúp bạn không chỉ “thu thập log”, mà thực sự quản lý chất lượng và ổn định của ChatGPT App.

13. Lỗi điển hình khi làm việc với log có cấu trúc và tương quan

Lỗi số 1: thiếu trace_id thống nhất xuyên suốt các dịch vụ.
Tình huống cổ điển: MCP‑gateway sinh một ID, commerce backend sinh một ID khác, webhook thì chẳng biết gì về tương quan, còn trong log của widget thì trace_id không hề xuất hiện. Hệ quả là tương quan biến thành tìm kiếm thủ công “có vẻ thời gian trùng nhau”. Cách đúng — sinh trace_id ở các điểm vào bạn kiểm soát (MCP server cho tool “hẹp”, backend/gateway — cho các lệnh fetch() từ widget) và kéo nó qua mọi biên giới: header HTTP, trường JSON, ngữ cảnh của agent.

Lỗi số 2: cố gắng sinh trace_id ở widget và coi đó là “chân lý”.
Đôi khi có vẻ hợp lý: “hãy dùng crypto.randomUUID() ngay trong React widget và gắn vào header”. Vấn đề là khi đó trace_id “sống” ở client và có thể không trùng với tracing server thực (OpenTelemetry, gateway, dịch vụ khác). Đáng tin hơn là để trace_id xuất hiện nơi bạn kiểm soát toàn bộ đường đi server: Next.js backend, API gateway hoặc MCP server. Widget nếu muốn chỉ đọc và log ID này.

Lỗi số 3: log PII và bí mật “cho tiện debug”.
Lúc đầu phát triển “rất tiện” khi ghi vào log toàn bộ prompt, token, số thẻ và email. Vài tháng sau nó biến thành “bom nổ chậm”: quyền truy cập log trở nên “độc hại”, audit bảo mật đặt câu hỏi khó chịu, và bạn sợ cả việc chụp màn hình lỗi. Ngay từ đầu hãy áp dụng scrub và đừng log thứ mà ngày mai bạn sẽ phải cuống cuồng xóa.

Lỗi số 4: log chuỗi không có cấu trúc ở một trong các lớp.
Đôi khi đội làm JSON log rất chuẩn ở MCP và commerce, nhưng ở widget lại để console.log("step 1", data). Hệ quả: đầu và cuối chuỗi vẫn bị “đứt”.

Lỗi số 5: lạm dụng mức ERROR.
Nếu mọi sai lệch nhỏ (kiểu “model trả 0 ứng viên, hiển thị fallback”) đều được log là ERROR, cảnh báo production sẽ đỏ liên tục. Cả đội nhanh chóng ngừng phản ứng với alert. Hãy phân biệt rành mạch: “WARN — lạ nhưng đã xử lý; ERROR — kịch bản người dùng thực sự hỏng”.

Lỗi số 6: schema log không thống nhất giữa các dịch vụ.
Khi ở một dịch vụ trường là traceId, ở dịch vụ khác là correlation_id, còn nơi thứ ba là requestId, thì không hệ thống log nào cứu được. Quan trọng là thống nhất một schema (như LogEvent) và bám theo nó trong mọi thành phần: App widget, MCP server, agent, ACP, webhook. Khi đó dựng dashboard xuyên suốt và điều tra sự cố chỉ còn là chuyện vài phút, không phải vài ngày.

Lỗi số 7: “tối ưu” dung lượng log bằng cách bỏ đi trường then chốt.
Đôi khi vì tiết kiệm, ai đó quyết định: “hãy bỏ user_id hoặc flow, cũng chỉ là chuyện nhỏ”. Rồi bất ngờ cần trả lời câu hỏi “nhóm người dùng nào checkout lỗi nhiều nhất?” — và phát hiện là không có dữ liệu. Nếu phải chọn thứ để bỏ, hãy bỏ payload văn bản dài (body request/response) và trường debug, chứ không phải mã nhận diện và thuộc tính ngữ cảnh cốt lõi.

Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION