1. Why you need structured logs in a ChatGPT App
Imagine your product manager writes: “Users complain that when choosing a gift we sometimes show an empty list, and sometimes checkout crashes. Can you fix it by tomorrow’s demo?” You have:
- ChatGPT, which sometimes calls your App and sometimes doesn’t.
- A widget in a sandbox.
- An MCP server that calls an external product database and the ACP.
- Webhooks from the payment provider.
And only scattered text logs like “something went wrong” somewhere on the MCP and “order failed” somewhere on the backend. With parallel requests this turns into chaos: it’s impossible to understand which log belongs to which user and which request.
Structured JSON logs and a single trace_id are exactly what you need to:
- view the entire chain by a single identifier: from the ChatGPT request to the "order.created" webhook;
- filter logs by service, tool, user, scenario;
- quickly answer “why did checkout fail” and “what did the agent do before it started hallucinating.”
In short: make production GiftGenius debuggable and monitorable on par with a regular microservice application.
2. String vs. structured logs: why console.log("oops") no longer works
In typical Next.js development many teams settle for string logs: they print a human-readable phrase and sometimes a couple of values. In a single service this is still tolerable. But in a ChatGPT App stack these logs very quickly become a mess.
A text log is just one line in a file or console. For example:
console.error(`Error in suggestGifts for user ${userId}: ${error.message}`);
When there are a hundred thousand such messages, finding “all MCP errors in checkout with userId=… for yesterday” is already non-trivial. And automatically building a dashboard of tool errors is nearly impossible.
A structured log is a JSON object where, in addition to the message text, there is a set of fields: level, time, service, identifiers, technical and business context. An analogue of the previous example:
logger.error({
message: "suggest_gifts failed",
user_id: userId,
trace_id,
service: "mcp",
tool_name: "suggest_gifts",
error_message: error.message,
});
Each field is indexed by the logging system (ELK, Loki, Better Stack, Datadog, etc.), and then you can write queries like service="mcp" AND level="error" AND tool_name="suggest_gifts" or simply search by trace_id="...".
For clarity — a small table.
| What we compare | String logs | Structured (JSON) logs |
|---|---|---|
| Parsing | Manually, via regex | Automatically by fields |
| Search by fields | Complex regexp queries | Simple expressions field=value |
| Aggregations and dashboards | Hard, lots of hacks | Trivial: count() , group by field |
| Context enrichment | In the message text | New fields without changing the schema |
| Request correlation | Almost impossible with parallel requests | Simple search by trace_id/request_id |
In the world of LLM applications, where half of the problems are not “a 500 error” but “the model called the wrong tool,” without structured logs you are literally blind.
3. Anatomy of a JSON log for a ChatGPT App
Next, let’s agree on a “minimal standard” log record that you’ll use across all layers of GiftGenius. It’s not perfect, but it covers 80% of tasks.
Let’s break log fields into several groups.
Technical fields
Technical fields are needed so that observability tools understand where the record came from at all.
We can describe them with a TypeScript type:
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; // Short event description
}
It’s better to write timestamp in ISO UTC format ("2025-11-21T10:15:30.123Z"), so that different services can be sorted by time without timezone juggling. service and env help separate, for example, production MCP logs from widget logs in dev. This is especially relevant if you later want to adopt OpenTelemetry and use common conventions like service.name, service.version, etc.
Correlation fields
This is the most important part of the lecture. Without these you won’t be able to link events together.
Add to our interface:
interface CorrelationFields {
trace_id: string; // End-to-end ID of the entire scenario
span_id?: string; // (optional) ID of a specific operation
parent_span_id?: string; // (optional) Parent operation
request_id?: string; // Local ID of the HTTP request or tool call
agent_run_id?: string; // Agent run ID (if any)
tool_call_id?: string; // ID of the specific tool call
checkout_session_id?: string; // ID of the ACP/payment session
}
trace_id is the main character. It must be the same across all logs related to the scenario “The user asked to pick a gift, we picked it, created the order, and received a webhook.” span_id and parent_span_id then allow you to build an “operation tree” in the style of distributed tracing, but to start you can get by with just trace_id and request_id.
Business context
A technical log without business context turns into “something happened, somewhere, sometime.” We need to understand which user and at what step of the scenario was affected.
Extend the interface:
interface BusinessFields {
user_id?: string; // Anonymous ID, NOT email
tenant_id?: string; // Organization/account, if B2B
flow?: string; // For example, "gift_recommendation" or "checkout"
step?: string; // For example, "collect_requirements" or "create_checkout"
}
The principle here is very simple: identifiers can be internal (UUID from your DB), but must not contain PII (email, phone, full name). We’ll cover more in the security section.
Error fields
Errors are a separate story. You typically want to split an error log into at least type, code, and text:
interface ErrorFields {
error_type?: "validation" | "upstream" | "timeout" | "system";
error_code?: string; // HTTP status, DB code, or your enum
error_message?: string; // Short and safe
stack?: string; // Stack, be careful with volume and PII
}
It’s important that error_message does not contain sensitive data (like “failed for card 4111 1111 1111 1111”). Better use "payment provider declined card" and some safe code.
Full log interface
Let’s put it all together:
export interface LogEvent
extends BaseLogFields,
CorrelationFields,
BusinessFields,
ErrorFields {
// leave room for additional fields
[key: string]: unknown;
}
You can use this interface in the MCP server, in the commerce backend, and in the agent. Then all services will write logs in the same format, and correlation becomes a pleasant stroll rather than a quest.
4. A minimal JSON logger for GiftGenius (MCP server)
Let’s start with something very minimal. Suppose your MCP server is a Node.js/TypeScript application. We’ll create a logger utility:
// 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,
};
// Output JSON to stdout — the log system will collect it
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),
};
This isn’t Pino or Winston, but for the course what matters is the idea: everything is written as JSON with proper fields.
Now let’s use it in the MCP tool handler suggest_gifts.
5. Logging an MCP tool: from entry to exit
Suppose you already have a handler for the suggest_gifts tool that accepts user preferences and returns a list of SKUs. Let’s add logs to it.
Assume we’ve already extracted a trace_id from the x-trace-id HTTP header (we’ll cover how it gets there in the next block on correlation).
// 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;
}
}
Now with a single trace_id you’ll be able to see:
- that the tool was invoked at all;
- how many candidates were found;
- at which step it failed.
And there’s no email or user’s name anywhere — only an internal user_id.
6. Where a trace_id is born in a ChatGPT App
Let’s figure out where a trace_id should be born. It is important to understand that it is not tied to a specific request. trace_id is specifically the identifier of a business operation. Therefore, you should distinguish two common situations:
“Narrow” MCP tool
This is when a tool performs one compact operation and immediately returns the result (without an interactive UI):
- get_gifts_for_budget
- calculate_price
- save_lead, etc.
In this case it’s convenient to treat it as: one MCP tool call = one business request = one trace. The end-to-end trace_id is created on the MCP gateway/server side upon tool-call entry (or taken from an existing tracing context if you use OpenTelemetry). Then this trace_id is used in all internal calls (REST services, databases, queues) and appears in logs as the trace_id field.
ChatGPT and the Apps SDK do not interfere here: they just send a JSON-RPC tool call, and tracing starts on your side, in the controlled zone.
“Wide” MCP tool (returns a widget)
Here the tool doesn’t finish the business operation to the end, but launches an interactive scene: it returns a widget that in the sandbox makes dozens of fetch() calls (loading a list of gifts, filters, checkout, etc.).
In such a scenario the end-to-end tracing is different:
- the main business operations live in the widget’s HTTP requests to the backend;
- therefore, each significant fetch() from the widget to your backend receives its own trace_id, which is created in the backend/gateway (the first server hop for that fetch).
Neither ChatGPT nor the widget are the “source of truth” for trace_id: they can only pass some auxiliary identifiers in the request (session_id, widget_id, user_id), while creation and management of trace_id happens on the server.
“Narrow” MCP tool: one trace per tool call
Let’s see what the flow looks like for a “narrow” tool without a widget:
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 (optionally with trace_id)
Pattern:
- upon tool-call entry into MCP you create a trace (or take an existing one from traceparent/x-trace-id);
- the entire subsequent path of this tool call (service calls, DBs, caches) is logged with the same trace_id;
- the widget doesn’t participate in the logs because there is no widget.
This approach gives you:
- a clear “snapshot” of a single operation: “MCP tool suggest_gifts → Gift API → Pricing API → response”;
- one trace_id per tool call.
“Wide” MCP tool: the widget and multiple traces
Now the GiftGenius scenario where the MCP tool returns a widget:
- ChatGPT calls the MCP tool, for example open_gift_widget.
- The MCP tool forms a widget description (layout, initial state) and returns it.
- The widget mounts in the sandbox and starts its own lifecycle:
- GET /api/gifts?budget=50&page=1
- GET /api/gifts?budget=50&filter=for_developers
- POST /api/checkout
- POST /api/save-lead
- Each such HTTP request hits your Next.js backend/gateway — and there you create a new trace:
fetch #1 -> trace_id = T-501 (load the first page of gifts)
fetch #2 -> trace_id = T-502 (apply the “for developers” filter)
fetch #3 -> trace_id = T-503 (create checkout)
...
That is:
- the MCP tool is “wide”: its main task is to open the widget, not to perform the entire business chain;
- the real business logic (list of gifts, choosing the top gift, checkout) lives in the backend that handles the widget’s fetch() calls;
- a group of fetch() requests, united by one business scenario, has its own unique trace_id, which you generate on the server upon HTTP request entry.
Additionally, you can pass along with each trace:
- session_id (ChatGPT session ID, if available),
- widget_id,
- user_id,
- tool_run_id or any other context.
Use trace_id to look at a specific operation (“checkout #3”), and session_id/widget_id to view everything that happened within a single widget/session.
7. Request correlation: how trace_id travels through the App, MCP, widget, and backend
Now for the most interesting part: how to ensure the necessary identifiers propagate through all layers: ChatGPT, the MCP server, the widget, the commerce backend, and webhooks.
Request flow with trace_id (diagram for the “wide” case)
A small scheme of how this looks for 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 the widget in the sandbox
Widget->>Backend: GET /api/gifts (trace_id = T-501, born in Backend)
Backend->>ACP: GET /gifts (x-trace-id = T-501)
ACP-->>Backend: 200 OK (trace_id = T-501)
Backend-->>Widget: JSON with gifts (trace_id = T-501 in logs)
Widget->>Backend: POST /api/checkout (trace_id = T-503, born in 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: Logs the event (trace_id = T-503)
Note:
- in this scheme trace_id is not generated by the widget;
- it appears at the HTTP request entry point to your backend (Next.js route handler, API gateway, etc.);
- then this trace_id is propagated:
- into backend logs,
- into the x-trace-id header when calling the ACP,
- into webhooks, if the ACP returns/passes it further.
6.5. Generating and propagating trace_id in the backend for calls from the widget
Let’s rewrite the example so it’s explicit: trace_id is born in the backend, not in the widget.
// app/api/mcp/tools/call/route.ts (Next.js backend, proxy to MCP)
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { logger } from "@/mcp/logging";
export async function POST(req: NextRequest) {
// If a trace_id came from the outside (e.g., from a gateway) — use it.
// If not, generate a new one at the backend entry.
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);
}
On the MCP server side we just read this header and use the trace_id in our logs (as in the examples from section 5).
The widget doesn’t even need to know about the existence of trace_id — it’s enough to call /api/mcp/tools/call. But if it’s convenient for you to display or log UI actions tied to tracing, you can return a trace_id in the response and log with something like service: "app-widget" in your own JSON logs (client-side or via SaaS analytics).
Example client-side MCP call from the widget
// 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",
// Do NOT generate trace_id here — it will be created in the backend
},
body: JSON.stringify({ toolName, args }),
});
// If the backend returns trace_id in the body, you can store it:
const data = await res.json();
return data;
}
If you want, you can extend the backend handler so it adds a trace_id to the JSON response, and then the widget can:
- log events like "service": "app-widget", "trace_id": "...",
- display trace links for developers.
But the principle remains the same: the source of trace_id is the server, not the widget.
Propagating trace_id further to ACP/commerce
Now inside the MCP tool create_checkout_session we call your commerce API and still carry the trace_id in headers:
// 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;
}
The commerce backend, in turn, also reads x-trace-id and writes it to its JSON logs. Then with a single trace_id you will see:
- the incoming HTTP request from the widget to the backend (where the trace was born);
- proxying to MCP (if it exists);
- the internal create_checkout_session call;
- the request to the commerce API;
- the commerce backend response;
- and, if it also propagates the header, the order.created webhook.
8. Log levels: DEBUG, INFO, WARN, ERROR in the context of an LLM application
Log levels help you avoid drowning in information. In a ChatGPT App it’s convenient to interpret them like this:
- DEBUG — detailed technical information, useful in dev/staging. For example, abbreviated prompts, the agent’s intermediate states, “raw” responses from external APIs (without PII). Use it very carefully in production.
- INFO — normal business events: “suggest_gifts succeeded, 10 candidates,” “checkout session created,” “webhook order.created processed.” These logs can stay enabled in prod.
- WARN — something went off the happy path, but the system kept working. For example: “fallback to cached catalog because upstream timeout,” “model returned invalid tool args, retry with different schema.”
- ERROR — a clear failure: the scenario did not complete as it should. For example: “checkout API failed,” “failed to persist order,” “tool crashed with unhandled exception.”
For convenience you can add a simple helper so you don’t hardcode strings:
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; // in dev, log everything
}
And call logger.debug only when shouldLogLevel("debug") returns true.
It’s especially dangerous in production to write DEBUG logs with the full prompt and the model’s response: they can easily contain passwords, keys, or any PII the user pasted into the chat by mistake.
9. Log security: PII scrub and secrets
It’s easy to overdo logging. If you log “everything,” you:
- will violate data protection laws;
- will make an attacker’s life easier (secrets and tokens can be pulled straight out of logs);
- will be afraid to grant anyone access to the logging system.
Therefore, the simple principle applies: logs should have enough information to understand what happened, but not enough to steal data.
Good practices:
- Log user_id, not email or phone number. If you absolutely need an email in logs for debugging, log its hash or mask it ("a***@gmail.com").
- Never write full tokens ("sk-..."), refresh tokens, client_secret, or passwords into logs. If you must — only the first/last 4 characters and the type (“sk-***1234”).
- Be careful with tool_input and tool_output. They may contain anything the user wrote. In production either don’t log them in full, or:
- log only typed fields that have already passed validation;
- truncate to a reasonable size and apply scrub — masking via regex (email, card numbers, etc.).
Simplest sanitizer example (highly simplified):
export function sanitize(text: string): string {
return text
.replace(/sk-[a-zA-Z0-9]{20,}/g, "sk-***redacted***")
.replace(/\b\d{16}\b/g, "****-****-****-****"); // cards
}
And when logging user input:
logger.debug({
message: "raw_user_message",
service: "app-widget",
trace_id,
user_id,
raw: sanitize(userMessage),
});
This code is far from production-grade, but it shows the idea well: first clean, then log.
10. Practice: the gift_recommended event for GiftGenius
Now let’s do the exercise: design the gift_recommended log event, which is written when GiftGenius finally chooses the “top gift” for the user.
The event should let you answer:
- which user (internal ID);
- which gift (SKU);
- by which scenario and at which step;
- which trace_id, to link it to other logs.
And at the same time, it must not contain PII or secrets.
Example:
{
"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"
}
What matters here:
- We log user_id, but not email or a name;
- SKU and price are normal business data and are not considered PII;
- reason_summary is a short technical tag, not the user’s full sentence;
- there is a trace_id and an agent_run_id, so you can see which tools the agent called on the way to this choice.
And here’s what you definitely should not log:
- the model’s response text in full with a “human” explanation;
- the user’s prompt (“I want a gift for my colleague Masha, her phone is …, address is …”);
- any payment data.
11. Log examples: a successful tool call and an ACP error
To reinforce — two small JSON examples.
Successful tools.call on 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
}
From just one such log you can already see:
- which tool;
- for which user;
- under which scenario;
- how long it took and how many candidates were returned.
Using the trace_id you will easily find related UI and agent logs for the same request.
ACP/checkout error
{
"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"
}
Again, no card number — only an error code and a safe message. And again, the same trace_id, so you can link this log with gift_recommended and see where in the chain things broke.
12. How not to turn logs into noise
It’s very tempting: “since we can log everything nicely, let’s log absolutely everything.” You’ll quickly get gigabytes of JSON noise where useful events get lost.
Some practical tips:
- Duplicative logs like “I entered function X” without additional information are not very helpful. It’s better to log meaningful events: start/end of a scenario, an external API call, a workflow step transition, errors.
- For frequent operations (e.g., catalog queries) you can enable sampling: log 1 of N requests in full and the rest only on errors.
- Keep DEBUG off in production (or very selective). If you do log prompts/responses — do it sparingly and with scrub.
We’ll talk about metrics and SLOs in the next lecture, but it’s already important to understand: logs are not only “for debugging,” they are the foundation of observability for the entire ChatGPT stack.
Remember the product manager from the beginning with the “empty list” and the crashing checkout? With the logging scheme described, you would find all requests with the required trace_id in a couple of minutes, look at suggest_gifts (how many candidates the tool returned, at which step it failed) and the "checkout failed" logs with the payment provider’s error_code. This is no longer an investigation of “log soup,” but a clear scenario “from request to webhook.”
In the end, a good logging stack for a ChatGPT App is not “we write something to stdout,” but:
- correct points of birth for trace_id (in the MCP gateway/server for “narrow” tools and at the backend entry for the widget’s fetch() in “wide” scenarios);
- a single trace_id across App → MCP → commerce → webhooks for each meaningful business call;
- a shared JSON log schema (service, env, user_id, flow, step, tool_name, etc.);
- careful handling of PII and secrets (scrub, masking, limited DEBUG in production);
- meaningful log levels and no noise.
With this foundation, all other observability tools (metrics, SLOs, alerts) become much more useful and help you not just “collect logs,” but actually manage the quality and stability of your ChatGPT App.
13. Common mistakes when working with structured logs and correlation
Mistake #1: no single trace_id across services.
A classic case: the MCP gateway generates one ID, the commerce backend generates another, webhooks don’t know anything about correlation at all, and the widget logs don’t include a trace_id. As a result, correlation turns into manual searching like “well, the times kind of match here.” The right approach is to generate trace_id at controlled entry points (the MCP server for “narrow” tools, the backend/gateway for the widget’s fetch()) and carry it across all boundaries: HTTP headers, JSON fields, agent context.
Mistake #2: trying to generate the trace_id in the widget and treating it as “truth.”
It can feel logical: “let’s just do crypto.randomUUID() in the React widget and put it in headers.” The problem is that then the trace_id lives on the client and may not match real server-side tracing (OpenTelemetry, gateway, other services). It’s far more reliable when the trace_id appears where you control the entire server path: in the Next.js backend, API gateway, or MCP server. The widget, if desired, can only read and log that ID.
Mistake #3: logging PII and secrets “for debugging convenience.”
Early in development it’s “very convenient” to just log the whole prompt body, tokens, card numbers, and emails. A couple of months later this becomes a time bomb: access to logs turns toxic, the security audit asks unpleasant questions, and you’re afraid to even show a screenshot of an error. Implement scrub from the very beginning and don’t log what you’ll be desperately scrubbing tomorrow.
Mistake #4: string logs without structure in one of the layers.
Sometimes the team builds great JSON logs in MCP and commerce, but leaves console.log("step 1", data) in the widget. As a result, the beginning and the end of the chain remain disconnected.
Mistake #5: overusing the ERROR level.
If any minor deviation (like “the model returned 0 candidates, showing fallback”) is logged as ERROR, production alerts will constantly be red. The team quickly stops responding to alerts at all. Try to separate honestly: “WARN — weird, but we handled it; ERROR — the user scenario actually broke.”
Mistake #6: inconsistent log schemas between services.
When one service calls the field traceId, another correlation_id, and a third requestId, no logging system will save you. It’s important to agree on a single schema (like we did with LogEvent) and stick to it across all components: the App widget, the MCP server, agents, ACP, webhooks. Then building end-to-end dashboards and investigating incidents becomes a matter of minutes, not days.
Mistake #7: trying to “optimize” log size by dropping key fields.
Sometimes, in a race to save space, someone decides: “let’s remove user_id or flow, it’s minor anyway.” Then suddenly you need to answer, “for which users does checkout fail most often?” — and it turns out the information isn’t there. If you must choose what to drop, drop long text payloads (request/response bodies) and debug fields, not identifiers and key context attributes.
GO TO FULL VERSION