1. Навіщо вам узагалі потрібні структуровані логи в ChatGPT App
Уявіть, що вам пише менеджер продукту: «Користувачі скаржаться, що під час вибору подарунка іноді показується порожній список, а іноді падає checkout. Можете виправити до завтрашнього демо?». У вас є:
- ChatGPT, який інколи викликає ваш App, а інколи — ні.
- Віджет у пісочниці.
- MCP‑сервер, який звертається до зовнішньої бази товарів і ACP.
- Вебхуки від платіжного сервісу.
І лише розрізнені текстові логи на кшталт «something went wrong» десь у MCP і «order failed» десь у бекенді. За паралельних запитів це швидко перетворюється на хаос. Стає неможливо зрозуміти, який лог належить якому користувачеві та якому запиту.
Структуровані JSON‑логи та єдиний trace_id якраз і потрібні, щоб:
- за одним ідентифікатором бачити весь ланцюжок: від запиту ChatGPT до вебхука "order.created";
- фільтрувати логи за сервісом, інструментом, користувачем і сценарієм;
- швидко відповідати на запитання «чому впав checkout» і «що робив агент перед тим, як почав галюцинувати».
Отже, мета проста: зробити так, щоб GiftGenius у продакшні можна було відлагоджувати й відстежувати не гірше, ніж звичайний мікросервісний застосунок.
2. Текстові й структуровані логи: чому console.log("ой") уже не працює
У звичній Next.js‑розробці багато хто обмежується текстовими логами: виводять зрозумілу людині фразу й інколи додають кілька значень. В одному сервісі це ще терпимо. Але в стеку ChatGPT App такі логи дуже швидко перетворюються на плутанину.
Текстовий лог — це просто один рядок у файлі чи в консолі. Наприклад:
console.error(`Error in suggestGifts for user ${userId}: ${error.message}`);
Коли таких повідомлень сто тисяч, знайти «усі помилки MCP у checkout з userId=… за вчора» вже непросто. А автоматично зібрати дашборд за помилками інструментів — майже нереально.
Структурований лог — це 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 |
| Збагачення контекстом | Текстом у повідомленні | Новими полями — без зміни схеми |
| Кореляція запитів | Майже нереальна за паралельних запитів | Звичайний пошук за trace_id/request_id |
У світі застосунків із LLM, де половина проблем — це не «помилка 500», а «модель викликала не той інструмент», без структурованих логів ви фактично «сліпі».
3. Анатомія JSON‑лога для ChatGPT App
Далі домовимося про «мінімальний стандарт» лог-запису, який ви використовуватимете в усіх шарах 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 краще писати у форматі ISO 8601 (UTC) ("2025-11-21T10:15:30.123Z"). Тоді логи з різних сервісів можна сортувати за часом — без плутанини з часовими поясами. service і env допомагають відокремити, наприклад, логи MCP у продакшні від логів віджета в dev. Це особливо актуально, якщо згодом ви захочете інтегрувати OpenTelemetry та використовувати спільні конвенції service.name, service.version тощо.
Поля кореляції
Це найважливіше для цієї лекції. Без них ви не зможете пов’язати події між собою.
Додамо до нашого інтерфейсу:
interface CorrelationFields {
trace_id: string; // Наскрізний ID усього сценарію
span_id?: string; // (опціонально) ID конкретної операції
parent_span_id?: string; // (опціонально) Батьківська операція
request_id?: string; // Локальний ID HTTP-запиту або tool-call
agent_run_id?: string; // ID запуску агента (якщо є)
tool_call_id?: string; // ID виклику конкретного інструмента
checkout_session_id?: string; // ID ACP/платіжної сесії
}
trace_id — ключове поле. Воно має бути однаковим в усіх логах, що належать до сценарію «Користувач попросив підібрати подарунок, ми підібрали, створили замовлення, отримали webhook». span_id і parent_span_id дозволяють потім будувати «дерево операцій» у стилі розподіленого трасування (distributed tracing). Але для старту можна обійтися навіть лише trace_id і request_id.
Бізнес‑контекст
Технічний лог без бізнес‑контексту перетворюється на «сталося щось, десь, колись». Нам потрібно розуміти, який користувач і на якому кроці сценарію був задіяний.
Розширимо інтерфейс:
interface BusinessFields {
user_id?: string; // Анонімний ID, НЕ email
tenant_id?: string; // Організація/акаунт, якщо B2B
flow?: string; // Наприклад, "gift_recommendation" або "checkout"
step?: string; // Наприклад, "collect_requirements" або "create_checkout"
}
Принцип тут простий: ідентифікатори можуть бути внутрішніми (UUID із вашої БД), але не повинні містити персональних даних (PII): email, телефон, ПІБ. Про це ще поговоримо в розділі про безпеку.
Поля помилок
Помилки — окрема історія. Типовий лог із помилкою зазвичай хочеться розкласти щонайменше на тип, код і текст:
interface ErrorFields {
error_type?: "validation" | "upstream" | "timeout" | "system";
error_code?: string; // HTTP-статус, код БД або власний enum
error_message?: string; // Коротко й безпечно
stack?: string; // Стек, обережно з обсягом і PII
}
Важливо, щоб error_message не містив чутливих даних (наприклад, «failed for card 4111 1111 1111 1111»). Краще "payment provider declined card" і якийсь безпечний код.
Повний інтерфейс лога
Зберемо все разом:
export interface LogEvent
extends BaseLogFields,
CorrelationFields,
BusinessFields,
ErrorFields {
// для додаткових полів залишимо запас
[key: string]: unknown;
}
Такий інтерфейс ви можете використовувати і в MCP‑сервері, і в commerce‑бекенді, і в агенті. Тоді всі сервіси писатимуть логи в одному форматі, а кореляція перетвориться на приємну прогулянку, а не на квест.
4. Найпростіший JSON‑логер для GiftGenius (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. Додаймо туди логи.
Нехай ми заздалегідь дістали trace_id з HTTP‑заголовка x-trace-id (як його туди покласти — розберемо в наступному блоці про кореляцію).
// mcp/tools/suggestGifts.ts
import { logger } from "../logging";
export async function suggestGiftsTool(args: SuggestGiftsArgs, ctx: {
traceId: string;
userId?: string;
}) {
logger.info({
message: "suggest_gifts called",
service: "mcp",
trace_id: ctx.traceId,
user_id: ctx.userId,
tool_name: "suggest_gifts",
flow: "gift_recommendation",
step: "fetch_candidates",
});
try {
const gifts = await fetchGiftsFromCatalog(args);
logger.info({
message: "suggest_gifts succeeded",
service: "mcp",
trace_id: ctx.traceId,
user_id: ctx.userId,
tool_name: "suggest_gifts",
flow: "gift_recommendation",
step: "rank_candidates",
result_count: gifts.length,
});
return gifts;
} catch (error: any) {
logger.error({
message: "suggest_gifts failed",
service: "mcp",
trace_id: ctx.traceId,
user_id: ctx.userId,
tool_name: "suggest_gifts",
flow: "gift_recommendation",
step: "fetch_candidates",
error_type: "upstream",
error_message: error.message,
});
throw error;
}
}
Тепер за одним trace_id можна буде побачити:
- що інструмент узагалі був викликаний;
- скільки кандидатів знайшлося;
- на якому кроці він упав.
При цьому ніде не фігурує email чи імʼя користувача — лише внутрішній user_id.
6. Де «народжується» trace_id у ChatGPT App
Розберімося, де має «народжуватися» trace_id. Важливо розуміти: він не привʼязаний до конкретного HTTP‑запиту. trace_id — це ідентифікатор саме бізнес‑операції. Тож варто розрізняти дві типові ситуації:
«Вузький» MCP‑інструмент
Це коли інструмент виконує одну компактну операцію та відразу повертає результат (без інтерактивного UI):
- get_gifts_for_budget
- calculate_price
- save_lead тощо.
У цьому випадку зручно мислити так: один виклик MCP‑інструмента = один бізнес‑запит = один trace. Наскрізний trace_id з’являється на боці MCP‑gateway / MCP‑сервера під час входу tool‑call (або береться з уже наявного контексту трасування, якщо ви використовуєте OpenTelemetry). Далі цей trace_id використовується в усіх внутрішніх викликах (REST‑сервіси, бази, черги) і потрапляє до логів як поле trace_id.
ChatGPT і Apps SDK тут ніяк не втручаються: вони просто надсилають JSON‑RPC tool‑call, а трасування починається у вас — у зоні, яку ви контролюєте.
«Широкий» MCP‑інструмент (повертає віджет)
Тут інструмент не завершує бізнес‑операцію до кінця, а запускає інтерактивну сцену: повертає віджет, який уже в пісочниці робить десятки fetch()‑запитів (підвантаження списку подарунків, фільтри, checkout тощо).
У такому сценарії наскрізне трасування влаштовано інакше:
- основні бізнес‑операції живуть в HTTP‑запитах віджета до бекенда;
- тому кожен значущий fetch() із віджета до вашого бекенда отримує свій trace_id, який «народжується» вже в бекенді / gateway (у першій серверній ланці для цього fetch).
Ні ChatGPT, ні сам віджет не є «джерелом істини» для trace_id. Вони лише можуть передати в запит якісь допоміжні ідентифікатори (session_id, widget_id, user_id). А от створення та керування trace_id відбувається на сервері.
«Вузький» MCP‑tool: один trace на tool‑call
Розберемо, як виглядає потік для «вузького» інструмента без віджета:
sequenceDiagram
participant ChatGPT as ChatGPT / Agent
participant MCP as MCP Server
participant GiftAPI as Gift API
participant Pricing as Pricing API
ChatGPT->>MCP: JSON-RPC tools.call get_gifts
MCP->>MCP: start trace (trace_id = T-123)
MCP->>GiftAPI: GET /gifts (x-trace-id = T-123)
GiftAPI-->>MCP: 200 OK (trace_id = T-123)
MCP->>Pricing: GET /price (x-trace-id = T-123)
Pricing-->>MCP: 200 OK (trace_id = T-123)
MCP-->>ChatGPT: tool result (опційно з trace_id)
Патерн:
- під час входу tool‑call у MCP ви створюєте trace (або берете вже наявний із traceparent/x-trace-id);
- усі подальші кроки цього tool‑call (виклики сервісів, БД, кешів) логуються з одним і тим самим trace_id;
- у логах немає участі віджета, бо жодного віджета немає.
Такий підхід дає:
- чіткий «знімок» однієї операції: «MCP‑tool suggest_gifts → Gift API → Pricing API → відповідь»;
- один trace_id на один виклик інструмента.
«Широкий» MCP‑tool: віджет і кілька трейсів
Тепер сценарій GiftGenius, де MCP‑інструмент повертає віджет:
- ChatGPT викликає MCP‑tool, наприклад open_gift_widget.
- MCP‑tool формує опис віджета (layout, initial state) і повертає його.
- Віджет монтується в пісочниці та починає жити своїм життям:
- 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 — і там ви створюєте новий trace:
fetch #1 -> trace_id = T-501 (завантажити першу сторінку подарунків)
fetch #2 -> trace_id = T-502 (застосувати фільтр «для розробників»)
fetch #3 -> trace_id = T-503 (створити checkout)
...
Тобто:
- MCP‑tool «широкий»: його основне завдання — відкрити віджет, а не виконати весь бізнес‑ланцюжок;
- реальна бізнес‑логіка (список подарунків, вибір топ‑подарунка, checkout) живе в бекенді, який обробляє fetch() віджета;
- група fetch()‑запитів, об’єднана одним бізнес‑сценарієм, має свій унікальний trace_id, який ви генеруєте на сервері під час входу HTTP‑запиту.
Додатково ви можете «прокидати» в кожен trace:
- session_id (ID сесії ChatGPT, якщо є),
- widget_id,
- user_id,
- tool_run_id або будь-який інший контекст.
За trace_id ви дивитеся конкретну операцію («checkout #3»), а за session_id / widget_id — усе, що відбувалося в межах одного віджета/сеансу.
7. Кореляція запитів: як trace_id проходить через App, MCP, віджет і backend
Переходимо до найцікавішого: як зробити так, щоб потрібні ідентифікатори проходили через усі шари — ChatGPT, MCP‑сервер, віджет, commerce‑бекенд і вебхуки.
Потік запитів із 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 не генерується віджетом;
- він з’являється в точці входу HTTP‑запиту на ваш бекенд (Next.js route handler, API‑gateway тощо);
- далі цей trace_id «прокидається»:
- у логи бекенда,
- у заголовок x-trace-id під час виклику ACP,
- у вебхуки, якщо ACP його повертає/передає далі.
7.1. Генеруємо й «прокидаємо» trace_id у backend для викликів із віджета
Перепишемо приклад так, щоб було явно видно: trace_id «народжується» у бекенді, а не у віджеті.
// app/api/mcp/tools/call/route.ts (Next.js backend, proxy до MCP)
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { logger } from "@/mcp/logging";
export async function POST(req: NextRequest) {
// Якщо прийшов trace_id із зовнішнього світу (наприклад, із gateway) — використовуємо його.
// Якщо ні — генеруємо новий на вході в 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 у своїх логах (як у прикладах із розділу 5).
Віджет при цьому може навіть не знати про існування trace_id — йому достатньо викликати /api/mcp/tools/call. Але якщо вам зручно відображати або логувати дії UI з прив’язкою до трасування, можна повертати trace_id у відповіді й писати, наприклад, service: "app-widget" у власні JSON‑логи (клієнтські або через SaaS‑аналітику).
Приклад клієнтського виклику MCP із віджета
// app/lib/mcpClient.ts (віджет)
export async function callMcpTool(toolName: string, args: unknown) {
const res = await fetch("/api/mcp/tools/call", {
method: "POST",
headers: {
"Content-Type": "application/json",
// trace_id НЕ генеруємо тут — він народиться в backend
},
body: JSON.stringify({ toolName, args }),
});
// Якщо backend поверне trace_id у тілі, можна його зберегти:
const data = await res.json();
return data;
}
За потреби ви можете розширити backend‑обробник, щоб він додавав trace_id у JSON‑відповідь. Тоді віджет зможе:
- логувати події виду "service": "app-widget", "trace_id": "...",
- відображати trace‑посилання для розробників.
Але принцип лишається незмінним: джерело trace_id — сервер, а не віджет.
«Прокидаємо» trace_id далі в ACP/commerce
Тепер усередині MCP‑інструмента create_checkout_session ми викликаємо ваш commerce API і все ще несемо trace_id у заголовках:
// mcp/tools/createCheckout.ts
import { logger } from "../logging";
export async function createCheckoutTool(
args: CreateCheckoutArgs,
ctx: { traceId: string; userId?: string }
) {
logger.info({
message: "create_checkout called",
service: "mcp",
trace_id: ctx.traceId,
user_id: ctx.userId,
tool_name: "create_checkout_session",
flow: "checkout",
step: "create_session",
});
const res = await fetch(process.env.COMMERCE_URL + "/checkout", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-trace-id": ctx.traceId,
},
body: JSON.stringify({
userId: ctx.userId,
...args,
}),
});
if (!res.ok) {
logger.error({
message: "checkout API failed",
service: "mcp",
trace_id: ctx.traceId,
user_id: ctx.userId,
flow: "checkout",
step: "create_session",
error_type: "upstream",
error_code: String(res.status),
});
throw new Error("Checkout API failed");
}
const data = await res.json();
logger.info({
message: "checkout session created",
service: "mcp",
trace_id: ctx.traceId,
user_id: ctx.userId,
flow: "checkout",
step: "create_session",
checkout_session_id: data.sessionId,
});
return data;
}
Commerce‑бекенд, своєю чергою, також читає x-trace-id і пише його у свої JSON‑логи. Тоді за одним trace_id ви побачите:
- вхідний HTTP‑запит від віджета в бекенд (де trace «народився»);
- проксіювання в MCP (якщо воно є);
- внутрішній виклик create_checkout_session;
- запит у commerce API;
- відповідь commerce‑бекенда;
- і, якщо він теж передає заголовок далі, webhook order.created.
8. Рівні логів: DEBUG, INFO, WARN, ERROR у контексті LLM‑застосунку
Рівні логів допомагають не загубитися в інформації. У ChatGPT App їх зручно трактувати так:
- DEBUG — детальна технічна інформація, корисна в dev/staging. Наприклад, скорочені промпти, проміжні стани агента, «сирі» відповіді зовнішніх API (без PII). У продакшні з цим рівнем потрібно бути дуже обережними.
- INFO — нормальні бізнес‑події: «suggest_gifts succeeded, 10 кандидатів», «checkout session created», «webhook order.created processed». Ці логи можна залишати увімкненими в продакшні.
- WARN — щось пішло нестандартно, але система продовжила працювати. Наприклад: «fallback to cached catalog because upstream timeout», «model returned invalid tool args, retry with different schema».
- ERROR — явний провал: сценарій не завершився як треба. Наприклад: «checkout API failed», «failed to persist order», «tool crashed with unhandled exception».
Для зручності можна додати просту допоміжну функцію, щоб не писати рівні вручну:
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 вмикаємо все
}
І викликати logger.debug лише тоді, коли shouldLogLevel("debug") повертає true.
Особливо небезпечно в продакшні писати DEBUG‑логи з повним промптом і відповіддю моделі: там легко можуть опинитися паролі, ключі й будь‑які персональні дані, які користувач випадково вставив у чат.
9. Безпека логів: PII‑scrub і секрети
Із логами легко переборщити. Якщо писати «все підряд», ви:
- порушите закони про захист даних;
- полегшите життя зловмиснику (секрети й токени можна просто витягнути з логів);
- зрештою й самі почнете боятися, кому давати доступ до системи логування.
Тому принцип простий: у логах має бути достатньо інформації, щоб зрозуміти, що сталося, але недостатньо — щоб украсти дані.
Хороші практики:
- Логуємо user_id, а не email чи телефон. Якщо вам усе ж потрібен email у логах для відлагодження, логуйте його хеш або маскуйте ("a***@gmail.com").
- Ніколи не пишемо в логах повні токени ("sk-..."), refresh‑токени, client_secret, паролі. Якщо дуже потрібно — лише перші/останні 4 символи і тип («sk-***1234»).
- Обережно з tool_input і tool_output. У них може бути все, що написав користувач. У продакшні або не логуйте їх повністю, або:
- логуйте лише типізовані поля, що вже пройшли валідацію;
- обрізайте до розумного розміру й застосовуйте scrub — маскування регулярними виразами (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. Практика: подія gift_recommended для GiftGenius
Тепер зробімо ту саму вправу: спроєктуємо лог‑подію gift_recommended, яка записується, коли GiftGenius зрештою обирає «топ‑подарунок» для користувача.
Подія має дозволити відповісти на запитання:
- який користувач (внутрішній ID);
- який подарунок (SKU);
- за яким сценарієм і на якому кроці;
- який trace_id, щоб повʼязати з рештою логів.
І водночас не повинна містити PII і секретів.
Приклад:
{
"timestamp": "2025-11-21T10:22:33.456Z",
"level": "info",
"service": "agent",
"env": "prod",
"message": "gift_recommended",
"trace_id": "a3b9e8c2-1f47-4ec5-9bdf-9d4e0c123abc",
"agent_run_id": "run_7f1d2c",
"user_id": "u_123456",
"flow": "gift_recommendation",
"step": "final_choice",
"recommended_sku": "SKU-SPACE-MUG-001",
"price_cents": 2499,
"currency": "USD",
"reason_summary": "recipient_likes_space_and_practical_gadgets"
}
Що тут важливо:
- Ми логуємо user_id, але не email і не імʼя;
- SKU і ціна — це нормальні бізнес‑дані, які не вважаються PII;
- reason_summary — короткий технічний тег, а не повна фраза користувача;
- є trace_id і agent_run_id, щоб можна було подивитися, які інструменти викликав агент на шляху до цього вибору.
А от що логувати точно не потрібно:
- текст відповіді моделі повністю, разом із «людським» поясненням;
- промпт користувача («хочу подарунок для колеги Маші, у неї телефон такий‑то, адреса така‑то»);
- будь‑які платіжні дані.
11. Приклади логів: успішний tool‑call і помилка ACP
Для закріплення — два невеликі JSON‑приклади.
Успішний tools.call на MCP
{
"timestamp": "2025-11-21T10:20:00.000Z",
"level": "info",
"service": "mcp",
"env": "prod",
"message": "tools.call completed",
"trace_id": "a3b9e8c2-1f47-4ec5-9bdf-9d4e0c123abc",
"request_id": "req_01JCQ5CZ0YQ6TM7E5W8H3N3F2Y",
"tool_name": "suggest_gifts",
"user_id": "u_123456",
"flow": "gift_recommendation",
"step": "rank_candidates",
"result_count": 12,
"latency_ms": 430
}
Із одного такого лога вже видно:
- який інструмент;
- для якого користувача;
- за яким сценарієм;
- скільки зайняло часу й скільки кандидатів повернули.
За trace_id ви легко знайдете логи UI та агента, що належать до того самого запиту.
Помилка ACP/checkout
{
"timestamp": "2025-11-21T10:21:05.789Z",
"level": "error",
"service": "commerce",
"env": "prod",
"message": "checkout failed",
"trace_id": "a3b9e8c2-1f47-4ec5-9bdf-9d4e0c123abc",
"checkout_session_id": "cs_test_9YpQvJH8",
"user_id": "u_123456",
"flow": "checkout",
"step": "charge_customer",
"error_type": "upstream",
"error_code": "PAYMENT_DECLINED",
"error_message": "payment provider declined card",
"provider": "stripe",
"amount_cents": 2499,
"currency": "USD"
}
Знову жодного номера картки — лише код помилки та безпечне повідомлення. І знову той самий trace_id, тож ви можете повʼязати цей лог із gift_recommended і зрозуміти, на якому етапі ланцюжок зламався.
12. Як не перетворити логи на сміття
Дуже спокусливо діяти так: «раз ми вміємо гарно логувати, давайте логувати абсолютно все». У результаті ви швидко отримаєте гігабайти JSON‑шуму, у якому корисні події просто губляться.
Кілька практичних порад:
- Дублювальні логи на кшталт «я увійшов у функцію X» без додаткової інформації малокорисні. Краще логувати значущі події: початок/кінець сценарію, виклик зовнішнього API, перехід між кроками workflow, помилки.
- Для частих операцій (наприклад, запитів до каталогу товарів) можна ввімкнути семплінг (вибіркове логування): логувати 1 із N запитів повністю, а решту — лише в разі помилок.
- У продакшні тримайте DEBUG вимкненим (або вмикайте його дуже вибірково). Якщо логування промптів/відповідей потрібне — робіть це обмежено й зі scrub.
Про метрики й SLO ми окремо поговоримо в наступній лекції, але вже зараз важливо розуміти: логи — це не лише «для відлагодження». Це фундамент спостережуваності всього ChatGPT‑стеку.
Памʼятаєте менеджера продукту з початку лекції — із «порожнім списком» і checkout, що падає? За описаною схемою логів ви б за кілька хвилин знайшли всі запити з потрібним trace_id, подивилися suggest_gifts (скільки кандидатів повернув інструмент і на якому кроці він упав) та логи "checkout failed" з error_code від платіжного сервісу. Це вже не розслідування «за логовою кашею», а зрозумілий сценарій «від запиту до вебхука».
У підсумку хороший стек логування для ChatGPT App — це не «ми щось пишемо в stdout», а:
- коректні точки «народження» trace_id (у MCP‑gateway/сервері для «вузьких» tools і на вході бекенда — для fetch() віджета в «широких» сценаріях);
- єдиний trace_id крізь App → MCP → commerce → вебхуки для кожного змістовного бізнес‑виклику;
- спільна схема JSON‑логів (service, env, user_id, flow, step, tool_name тощо);
- акуратне поводження з PII і секретами (scrub, маскування, обмежений DEBUG у продакшні);
- змістовні рівні логів і мінімум шуму.
З такою базою всі інші інструменти спостережуваності (метрики, SLO, алерти) стають значно кориснішими й допомагають не просто «збирати логи», а реально керувати якістю та стабільністю вашого ChatGPT App.
13. Типові помилки під час роботи зі структурованими логами та кореляцією
Помилка № 1: відсутність єдиного trace_id через усі сервіси.
Класичний випадок: MCP‑gateway генерує один ID, commerce‑бекенд — другий, вебхуки взагалі нічого не знають про кореляцію, а в логах віджета trace_id не фігурує. У результаті кореляція перетворюється на ручний пошук «ну тут, схоже, час збігається». Правильний підхід — генерувати trace_id у контрольованих точках входу (MCP‑сервер для «вузьких» tools, backend/gateway — для fetch() з віджета) і протягувати його через усі межі: HTTP‑заголовки, JSON‑поля, контекст агента.
Помилка № 2: спроба генерувати trace_id у віджеті й вважати його «істиною».
Іноді здається логічним: «давайте одразу в React‑віджеті зробимо crypto.randomUUID() і будемо вішати його в заголовки». Проблема в тому, що тоді trace_id живе на клієнті й може не збігатися з реальним серверним трасуванням (OpenTelemetry, gateway, інші сервіси). Значно надійніше, щоб trace_id з’являвся там, де ви контролюєте весь серверний шлях: у Next.js backend, API‑gateway або MCP‑сервері. Віджет за бажанням може цей ID лише читати й логувати.
Помилка № 3: логування PII і секретів «для зручності відлагодження».
На початку розробки «дуже зручно» просто записати в лог усе тіло промпта, токени, номери карток і email. За кілька місяців це перетворюється на міну сповільненої дії: доступ до логів стає токсичним, аудит безпеки ставить неприємні запитання, а ви боїтеся навіть показати скриншот помилки. Від самого початку впроваджуйте scrub і не логуйте те, що завтра доведеться гарячково вичищати.
Помилка № 4: текстові логи без структури в одному зі шарів.
Іноді команда робить круті JSON‑логи в MCP і commerce, але у віджеті залишає console.log("step 1", data). У результаті початок і кінець ланцюжка залишаються розірваними.
Помилка № 5: зловживання рівнем ERROR.
Якщо будь‑яке незначне відхилення (типу «модель повернула 0 кандидатів, показуємо fallback») логувати як ERROR, продакшн‑алерти спрацьовуватимуть постійно. Команда швидко перестає реагувати на алерти взагалі. Намагайтеся чесно розділяти: «WARN — дивно, але ми викрутилися; ERROR — користувацький сценарій справді зламався».
Помилка № 6: неузгоджені схеми логів між сервісами.
Коли в одному сервісі поле називається traceId, у другому correlation_id, а в третьому requestId, жодна система логування не врятує. Важливо домовитися про єдину схему (як ми зробили з LogEvent) і дотримуватися її в усіх компонентах: App‑віджет, MCP‑сервер, агенти, ACP, вебхуки. Тоді побудова наскрізних дашбордів і розслідування інцидентів стане справою хвилин, а не днів.
Помилка № 7: спроба «оптимізувати» розмір логів шляхом викидання ключових полів.
Іноді в гонитві за економією місця хтось вирішує: «давайте приберемо user_id або flow, усе одно це дрібниця». Потім раптом потрібно відповісти на запитання «у яких користувачів найчастіше падає checkout?» — і виявляється, що інформації немає. Якщо й обирати, що прибирати, то довгі текстові payload-и (тіла запитів/відповідей) і відладочні поля, а не ідентифікатори та ключові контекстні атрибути.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ