CodeGym /Cursos /ChatGPT Apps /Logs estructurados y correlación de solicitudes

Logs estructurados y correlación de solicitudes

ChatGPT Apps
Nivel 17 , Lección 0
Disponible

1. Por qué necesitas logs estructurados en ChatGPT App

Imagina que el product manager te escribe: «Los usuarios se quejan de que al elegir un regalo a veces se muestra una lista vacía y a veces el checkout falla. ¿Se puede arreglar para la demo de mañana?». Tienes:

  • ChatGPT, que a veces invoca tu App y a veces no.
  • Un widget en una sandbox.
  • Un servidor MCP que consume una base externa de productos y ACP.
  • Webhooks del proveedor de pagos.

Y solo logs de texto dispersos como «something went wrong» en algún lugar del MCP y «order failed» en algún lugar del backend. Con solicitudes en paralelo, esto se convierte en un caos: es imposible entender qué log corresponde a qué usuario y a qué solicitud.

Los logs JSON estructurados y un trace_id único sirven precisamente para:

  • ver toda la cadena con un solo identificador: desde la solicitud de ChatGPT hasta el webhook "order.created";
  • filtrar logs por servicio, herramienta, usuario, escenario;
  • responder rápido a preguntas como «por qué se cayó el checkout» y «qué hacía el agente antes de empezar a alucinar».

En resumen, el objetivo es simple: que el GiftGenius en producción se pueda depurar y monitorizar tan bien como una aplicación de microservicios convencional.

2. Registros de texto vs estructurados: por qué console.log("ay") ya no funciona

En el desarrollo típico con Next.js muchos se conforman con logs de texto: imprimen una frase legible y, a veces, un par de valores. En un servicio único esto aún es tolerable. Pero en el stack de ChatGPT App estos logs se convierten muy rápido en un batiburrillo.

Un log de texto es solo una línea en un archivo o en la consola. Por ejemplo:


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

Cuando hay cien mil mensajes como estos, encontrar «todos los errores del MCP en checkout con userId=… de ayer» ya no es trivial. Y construir automáticamente un dashboard de errores de herramientas es casi imposible.

Un log estructurado es un objeto JSON donde, además del texto del mensaje, hay un conjunto de campos: nivel, hora, servicio, identificadores, contexto técnico y de negocio. Análogo al anterior:

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

Cada campo es indexado por el sistema de logging (ELK, Loki, Better Stack, Datadog, etc.), y después se pueden escribir consultas como service="mcp" AND level="error" AND tool_name="suggest_gifts" o simplemente buscar por trace_id="...".

Para mayor claridad — una pequeña tabla.

Qué comparamos Registros de texto Registros estructurados (JSON)
Análisis Manualmente, con regex Automáticamente por campos
Búsqueda por campos Consultas regexp complejas Expresiones simples field=value
Agregaciones y dashboards Difícil, muchos hacks Trivial: count() , group by field
Enriquecimiento con contexto Como texto en el mensaje Con nuevos campos sin cambiar el esquema
Correlación de solicitudes Casi imposible con solicitudes en paralelo Búsqueda normal por trace_id/request_id

En el mundo de las aplicaciones LLM, donde la mitad de los problemas no son «error 500», sino «el modelo llamó a la herramienta equivocada», sin logs estructurados estás literalmente a ciegas.

3. Anatomía de un log JSON para ChatGPT App

Acordemos un «estándar mínimo» de entrada de log que vas a usar en todas las capas de GiftGenius. No es perfecto, pero cubre el 80% de los casos.

Dividamos los campos del log en varios grupos.

Campos técnicos

Los campos técnicos son necesarios para que las herramientas de observabilidad entiendan de dónde llega la entrada.

Podemos describirlos con un tipo de TypeScript:

type LogLevel = "debug" | "info" | "warn" | "error";

interface BaseLogFields {
    timestamp: string;    // ISO 8601 UTC
    level: LogLevel;      // "info", "error", etc.
    service: string;      // "app-widget", "mcp", "agent", "commerce", "webhook"
    env: "dev" | "staging" | "prod";
    message: string;      // Descripción breve del evento
}

Es mejor escribir timestamp en formato ISO UTC ("2025-11-21T10:15:30.123Z"), así los distintos servicios se pueden ordenar por hora sin líos de zonas horarias. service y env ayudan a separar, por ejemplo, los logs del MCP en producción de los del widget en dev. Esto es especialmente útil si más tarde quieres integrarte con OpenTelemetry y usar convenciones comunes como service.name, service.version, etc.

Campos de correlación

Esto es lo más importante de esta lección. Sin ellos no podrás relacionar eventos entre sí.

Añadamos a nuestra interfaz:

interface CorrelationFields {
    trace_id: string;        // ID de extremo a extremo de todo el escenario
    span_id?: string;        // (opcional) ID de la operación concreta
    parent_span_id?: string; // (opcional) Operación padre
    request_id?: string;     // ID local de la solicitud HTTP o de la llamada a herramienta
    agent_run_id?: string;   // ID de la ejecución del agente (si existe)
    tool_call_id?: string;   // ID de la invocación de la herramienta concreta
    checkout_session_id?: string; // ID de la sesión ACP/de pago
}

trace_id es el protagonista. Debe ser el mismo en todos los logs relacionados con el escenario «El usuario pidió un regalo, lo seleccionamos, creamos el pedido, recibimos el webhook». span_id y parent_span_id permiten construir más tarde un «árbol de operaciones» al estilo del tracing distribuido, pero para empezar puedes apañarte solo con trace_id y request_id.

Contexto de negocio

Un log técnico sin contexto de negocio se convierte en «pasó algo, en algún sitio, en algún momento». Necesitamos entender qué usuario y en qué paso del escenario se vio afectado.

Ampliemos la interfaz:

interface BusinessFields {
    user_id?: string;     // ID anónimo, NO el email
    tenant_id?: string;   // Organización/cuenta, si es B2B
    flow?: string;        // Por ejemplo, "gift_recommendation" o "checkout"
    step?: string;        // Por ejemplo, "collect_requirements" o "create_checkout"
}

El principio aquí es muy simple: los identificadores pueden ser internos (UUID de tu BD), pero no deben contener PII (email, teléfono, nombre completo). Hablaremos más de ello en la sección de seguridad.

Campos de error

Los errores merecen capítulo aparte. En un log típico de error conviene separar al menos el tipo, el código y el texto:

interface ErrorFields {
    error_type?: "validation" | "upstream" | "timeout" | "system";
    error_code?: string;       // Estado HTTP, código de BD o tu propio enum
    error_message?: string;    // Breve y seguro
    stack?: string;            // Stack; cuidado con el tamaño y la PII
}

Es importante que error_message no contenga datos sensibles (como «failed for card 4111 1111 1111 1111»). Mejor "payment provider declined card" y algún código seguro.

Interfaz completa de log

Juntemos todo:

export interface LogEvent
    extends BaseLogFields,
        CorrelationFields,
        BusinessFields,
        ErrorFields {
    // dejamos margen para campos adicionales
    [key: string]: unknown;
}

Esta interfaz puedes usarla tanto en el servidor MCP como en el backend de commerce y en el agente. Así, todos los servicios escribirán logs en el mismo formato y la correlación será un paseo agradable, no una gymkana.

4. Logger JSON más simple para GiftGenius (servidor MCP)

Empecemos por algo minimalista. Supongamos que tu servidor MCP es una aplicación Node.js/TypeScript. Hagamos la utilidad 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,
    };

    // Escribimos JSON en stdout: el sistema de logs lo recogerá después
    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),
};

No es Pino ni Winston, pero para este curso lo importante es la idea: todo se escribe como JSON con campos coherentes.

Ahora lo usamos en el manejador de la herramienta MCP suggest_gifts.

5. Logging de la herramienta MCP: de la entrada a la salida

Supongamos que ya tienes un manejador de la herramienta suggest_gifts que recibe las preferencias del usuario y devuelve una lista de SKU. Añadamos logs.

Digamos que ya hemos extraído trace_id del encabezado HTTP x-trace-id (cómo ponerlo ahí lo veremos en el siguiente bloque sobre correlación).

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

Ahora, con un solo trace_id podrás ver:

  • que la herramienta fue invocada;
  • cuántos candidatos se encontraron;
  • en qué paso se cayó.

Y en ningún sitio aparece el email o el nombre del usuario: solo el user_id interno.

6. Dónde nace el trace_id en ChatGPT App

Veamos dónde debe nacer el trace_id. Es importante entender que no está ligado a una solicitud concreta. trace_id es el identificador de una operación de negocio. Por eso hay que distinguir dos situaciones típicas:

Herramienta MCP «estrecha»

Es cuando la herramienta hace una operación compacta y devuelve el resultado de inmediato (sin UI interactiva):

  • get_gifts_for_budget
  • calculate_price
  • save_lead, etc.

En este caso es conveniente considerar: una llamada a la herramienta MCP = una solicitud de negocio = un trace. El trace_id de extremo a extremo nace en el lado del gateway MCP / servidor MCP al entrar la llamada a la herramienta (o se toma del contexto de trazado existente si usas OpenTelemetry). Después este trace_id se usa en todas las llamadas internas (servicios REST, bases de datos, colas) y aparece en los logs como campo trace_id.

ChatGPT y el Apps SDK no intervienen aquí: solo envían la llamada JSON‑RPC a la herramienta, y el trazado empieza en tu zona controlada.

Herramienta MCP «ancha» (devuelve un widget)

Aquí la herramienta no completa la operación de negocio hasta el final, sino que inicia una escena interactiva: devuelve un widget que, ya en la sandbox, hace decenas de fetch() (cargar lista de regalos, filtros, checkout, etc.).

En este escenario la trazabilidad de extremo a extremo es distinta:

  • las operaciones de negocio principales viven en las solicitudes HTTP del widget al backend;
  • por lo tanto, cada fetch() significativo desde el widget a tu backend recibe su propio trace_id, que nace en el backend/gateway (el primer salto de servidor para ese fetch).

Ni ChatGPT ni el propio widget son la «fuente de la verdad» del trace_id: solo pueden enviar en la solicitud algunos identificadores auxiliares (session_id, widget_id, user_id), pero la creación y gestión del trace_id ocurre en el servidor.

MCP‑tool «estrecho»: un trace por tool‑call

Veamos cómo es el flujo para una herramienta «estrecha» sin 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 (opcional con trace_id)

Patrón:

  • al entrar la llamada a la herramienta en el MCP creas el trace (o tomas uno existente de traceparent/x-trace-id);
  • todo el camino posterior de esa llamada (llamadas a servicios, BD, cachés) se registra con el mismo trace_id;
  • los logs no incluyen el widget, porque no existe ningún widget.

Este enfoque ofrece:

  • una «instantánea» clara de una operación: «MCP‑tool suggest_gifts → Gift API → Pricing API → respuesta»;
  • un trace_id por invocación de herramienta.

MCP‑tool «ancho»: widget y varios traces

Ahora el escenario de GiftGenius, donde la herramienta MCP devuelve un widget:

  1. ChatGPT llama a la herramienta MCP, por ejemplo open_gift_widget.
  2. La herramienta MCP forma la descripción del widget (layout, estado inicial) y la devuelve.
  3. El widget se monta en la sandbox y empieza a vivir su propia vida:
    • GET /api/gifts?budget=50&page=1
    • GET /api/gifts?budget=50&filter=for_developers
    • POST /api/checkout
    • POST /api/save-lead
  4. Cada una de esas solicitudes HTTP llega a tu backend/gateway de Next.js — y ahí creas un nuevo trace:
fetch #1  -> trace_id = T-501  (cargar la primera página de regalos)
fetch #2  -> trace_id = T-502  (aplicar el filtro «para desarrolladores»)
fetch #3  -> trace_id = T-503  (crear checkout)
...

Es decir:

  • la herramienta MCP es «ancha»: su objetivo principal es abrir el widget, no ejecutar toda la cadena de negocio;
  • la lógica de negocio real (lista de regalos, selección del top, checkout) vive en el backend, que procesa los fetch() del widget;
  • el grupo de fetch() relacionados por un mismo escenario de negocio tiene su propio trace_id, que generas en el servidor al entrar la solicitud HTTP.

Además puedes propagar en cada trace:

  • session_id (ID de sesión de ChatGPT, si existe),
  • widget_id,
  • user_id,
  • tool_run_id u otro contexto.

Con trace_id miras una operación concreta («checkout #3»), con session_id / widget_id ves todo lo que ocurrió dentro de un mismo widget/sesión.

7. Correlación de solicitudes: cómo pasa el trace_id por App, MCP, widget y backend

Pasemos a la parte más interesante: cómo hacer que los identificadores necesarios atraviesen todas las capas: ChatGPT, servidor MCP, widget, backend de commerce y webhooks.

Flujo de solicitudes con trace_id (diagrama del caso «ancho»)

Un esquema sencillo de cómo es en 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 del widget en la sandbox

    Widget->>Backend: GET /api/gifts (trace_id = T-501, nace en el Backend)
    Backend->>ACP: GET /gifts (x-trace-id = T-501)
    ACP-->>Backend: 200 OK (trace_id = T-501)
    Backend-->>Widget: JSON con regalos (trace_id = T-501 en los logs)

    Widget->>Backend: POST /api/checkout (trace_id = T-503, nace en el 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: Registra el evento (trace_id = T-503)

Ten en cuenta:

  • en este esquema el trace_id no lo genera el widget;
  • aparece en el punto de entrada de la solicitud HTTP a tu backend (route handler de Next.js, API‑gateway, etc.);
  • después ese trace_id se propaga:
    • en los logs del backend,
    • en el encabezado x-trace-id al llamar a ACP,
    • en los webhooks, si ACP lo devuelve/propaga más allá.

6.5. Generamos y propagamos el trace_id en el backend para llamadas desde el widget

Reescribamos el ejemplo para que se vea claro: el trace_id nace en el backend, no en el widget.

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

export async function POST(req: NextRequest) {
  // Si llega un trace_id del exterior (por ejemplo, del gateway), lo usamos.
  // Si no, generamos uno nuevo en la entrada al 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);
}

En el lado del servidor MCP simplemente leemos ese encabezado y usamos trace_id en nuestros logs (como en los ejemplos de la sección 5).

El widget ni siquiera necesita saber de la existencia de trace_id: le basta con llamar a /api/mcp/tools/call. Pero si te resulta útil mostrar o registrar acciones de UI ligadas al trazado, puedes devolver trace_id en la respuesta y escribir, por ejemplo, service: "app-widget" en tus propios logs JSON (del cliente o a través de analítica SaaS).

Ejemplo de llamada de cliente al MCP desde el 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",
      // NO generamos trace_id aquí: nacerá en el backend
    },
    body: JSON.stringify({ toolName, args }),
  });

  // Si el backend devuelve el trace_id en el cuerpo, se puede guardar:
  const data = await res.json();
  return data;
}

Si quieres, puedes ampliar el manejador del backend para que añada trace_id en la respuesta JSON, y entonces el widget podrá:

  • registrar eventos del tipo "service": "app-widget", "trace_id": "...",
  • mostrar enlaces de trace para desarrolladores.

Pero el principio sigue siendo el mismo: la fuente del trace_id es el servidor, no el widget.

Propagamos el trace_id a ACP/commerce

Ahora, dentro de la herramienta MCP create_checkout_session llamamos a tu commerce API y seguimos llevando el trace_id en los encabezados:

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

El backend de commerce, a su vez, también lee x-trace-id y lo escribe en sus logs JSON. Así, con un solo trace_id verás:

  • la solicitud HTTP entrante del widget al backend (donde nació el trace);
  • el proxy al MCP (si existe);
  • la llamada interna create_checkout_session;
  • la solicitud al commerce API;
  • la respuesta del backend de commerce;
  • y, si también lo propaga, el webhook order.created.

8. Niveles de log: DEBUG, INFO, WARN, ERROR en el contexto de una aplicación LLM

Los niveles de log ayudan a no ahogarse en información. En ChatGPT App resulta útil interpretarlos así:

  • DEBUG — información técnica detallada, útil en dev/staging. Por ejemplo, prompts abreviados, estados intermedios del agente, respuestas «crudas» de APIs externas (sin PII). En producción hay que ser muy prudente.
  • INFO — eventos de negocio normales: «suggest_gifts succeeded, 10 candidatos», «checkout session created», «webhook order.created processed». Estos logs pueden permanecer activos en producción.
  • WARN — algo fue inusual, pero el sistema siguió funcionando. Por ejemplo: «fallback to cached catalog because upstream timeout», «model returned invalid tool args, retry with different schema».
  • ERROR — fallo claro: el escenario no terminó como debía. Por ejemplo: «checkout API failed», «failed to persist order», «tool crashed with unhandled exception».

Para mayor comodidad, puedes añadir un helper simple para no escribir cadenas a mano:

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; // en dev activamos todo
}

Y llamar a logger.debug solo cuando shouldLogLevel("debug") devuelve true.

Especialmente peligroso en producción es escribir logs DEBUG con el prompt completo y la respuesta del modelo: ahí pueden colarse contraseñas, claves y cualquier PII que el usuario haya pegado por error en el chat.

9. Seguridad de los logs: PII‑scrub y secretos

Con los logs es fácil excederse. Si escribes «todo lo que pasa», puedes:

  • incumplir la normativa de protección de datos;
  • facilitar la vida a un atacante (secretos y tokens pueden extraerse directamente de los logs);
  • temer dar acceso a tu sistema de logs a cualquiera.

Por eso aplica un principio sencillo: en los logs hay la información suficiente para entender qué ha ocurrido, pero no la suficiente para robar datos.

Buenas prácticas:

  1. Registramos user_id, no el email o el teléfono. Si de verdad necesitas el email en los logs para depurar, registra su hash o enmascáralo ("a***@gmail.com").
  2. Nunca escribas en los logs tokens completos ("sk-..."), refresh tokens, client_secret, contraseñas. Si es absolutamente necesario, solo los primeros/últimos 4 caracteres y el tipo («sk-***1234»).
  3. Cuidado con tool_input y tool_output. Pueden contener todo lo que escribió el usuario. En producción o no los registres enteros, o:
    • registra solo campos tipados que ya pasaron validación;
    • recórtalos a un tamaño razonable y aplica scrub — enmascarado por regex (email, números de tarjeta, etc.).

Ejemplo sencillo de sanitizador (muy simplificado):

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

Y al registrar la entrada del usuario:

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

Este código está lejos del nivel industrial, pero deja clara la idea: primero limpiamos, luego registramos.

10. Práctica: evento gift_recommended para GiftGenius

Ahora hagamos el ejercicio: diseñar el evento de log gift_recommended, que se escribe cuando GiftGenius elige definitivamente el «regalo top» para el usuario.

El evento debe permitir responder a:

  • qué usuario (ID interno);
  • qué regalo (SKU);
  • según qué escenario y en qué paso;
  • qué trace_id, para enlazar con el resto de logs.

Y a la vez no debe contener PII ni secretos.

Ejemplo:

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

Qué es importante aquí:

  • Registramos user_id, pero no el email ni el nombre;
  • El SKU y el precio son datos de negocio normales, no se consideran PII;
  • reason_summary es una etiqueta técnica breve, no la frase completa del usuario;
  • Hay trace_id y agent_run_id, para poder ver qué herramientas invocó el agente en el camino hasta esta elección.

Y lo que seguro no debe registrarse:

  • el texto completo de la respuesta del modelo con la explicación «humana»;
  • el prompt del usuario («quiero un regalo para una compañera, tiene tal teléfono, dirección tal…»);
  • cualquier dato de pago.

11. Ejemplos de logs: tool‑call exitoso y error de ACP

Para afianzar — dos pequeños ejemplos JSON.

tools.call exitoso en el 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
}

De un solo log así ya se ve:

  • qué herramienta;
  • para qué usuario;
  • según qué escenario;
  • cuánto tardó y cuántos candidatos devolvió.

Con el trace_id encuentras fácilmente los logs de UI y del agente relacionados con la misma solicitud.

Error de 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"
}

De nuevo, ningún número de tarjeta, solo el código de error y un mensaje seguro. Y, otra vez, el mismo trace_id, por lo que puedes enlazar este log con gift_recommended y entender en qué punto se rompió la cadena.

12. Cómo no convertir los logs en basura

Es muy tentador: «ya que podemos registrar todo bonito, registremos absolutamente todo». Así obtendrás rápidamente gigabytes de ruido JSON donde los eventos útiles se pierden.

Algunos consejos prácticos:

  • Logs duplicados del tipo «he entrado en la función X» sin información añadida son poco útiles. Mejor registra eventos significativos: inicio/fin de escenario, llamada a API externa, transición de paso en el workflow, errores.
  • Para operaciones frecuentes (por ejemplo, consultas al catálogo de productos) puedes activar sampling: registrar 1 de cada N solicitudes completas, y el resto solo en caso de error.
  • En producción mantén DEBUG desactivado (o muy selectivo). Si vas a registrar prompts/respuestas, que sea de forma limitada y con scrub.

Hablaremos de métricas y SLO en la siguiente lección, pero ya ahora es importante entender: los logs no son solo «para depurar», son el cimiento de la observabilidad de todo el stack de ChatGPT.

¿Recuerdas al product manager del inicio con la «lista vacía» y el checkout que cae? Con el esquema descrito encontrarías en un par de minutos todas las solicitudes con el trace_id deseado, verías suggest_gifts (cuántos candidatos devolvió la herramienta, en qué paso cayó) y los logs de "checkout failed" con error_code del proveedor de pagos. Ya no es una investigación «en la papilla de logs», sino un escenario claro «de la solicitud al webhook».

Al final, un buen stack de logging para ChatGPT App no es «escribimos algo en stdout», sino:

  • lugares correctos de nacimiento del trace_id (en el gateway/servidor MCP para herramientas «estrechas» y en la entrada del backend para los fetch() del widget en escenarios «anchos»);
  • un trace_id único a través de App → MCP → commerce → webhooks para cada llamada de negocio con sentido;
  • un esquema común de logs JSON (service, env, user_id, flow, step, tool_name, etc.);
  • trato cuidadoso de PII y secretos (scrub, enmascarado, DEBUG limitado en producción);
  • niveles de log con sentido y ausencia de ruido.

Con esta base, el resto de herramientas de observabilidad (métricas, SLO, alertas) se vuelven mucho más útiles y ayudan no solo a «recoger logs», sino a gestionar de verdad la calidad y la estabilidad de tu ChatGPT App.

13. Errores típicos al trabajar con logs estructurados y correlación

Error nº 1: ausencia de un trace_id único a través de todos los servicios.
Caso clásico: el gateway MCP genera un ID, el backend de commerce otro, los webhooks no saben nada de correlación, y en los logs del widget no aparece trace_id. Como resultado, la correlación se convierte en una búsqueda manual de «bueno, parece que coincide la hora». El enfoque correcto es generar el trace_id en puntos de entrada controlados (servidor MCP para herramientas «estrechas», backend/gateway para los fetch() del widget) y arrastrarlo a través de todas las fronteras: encabezados HTTP, campos JSON, contexto del agente.

Error nº 2: intentar generar el trace_id en el widget y considerarlo «la verdad».
A veces parece lógico: «hagamos en el widget de React un crypto.randomUUID() y lo pondremos en los encabezados». El problema es que entonces el trace_id vive en el cliente y puede no coincidir con el trazado real del servidor (OpenTelemetry, gateway, otros servicios). Es mucho más fiable que el trace_id aparezca donde controlas todo el camino del servidor: en el backend de Next.js, el API‑gateway o el servidor MCP. El widget, si quiere, puede solo leer ese ID y registrarlo.

Error nº 3: registrar PII y secretos «por comodidad de depuración».
Al principio del desarrollo es «muy cómodo» escribir en el log todo el cuerpo del prompt, tokens, números de tarjeta y email. A los pocos meses se convierte en una bomba de relojería: el acceso a los logs se vuelve tóxico, auditoría de seguridad hace preguntas incómodas y temes hasta enseñar una captura de pantalla del error. Desde el inicio aplica scrub y no registres lo que mañana tendrás que limpiar a toda prisa.

Error nº 4: logs de texto sin estructura en una de las capas.
A veces el equipo hace logs JSON estupendos en MCP y commerce, pero en el widget deja console.log("step 1", data). Como resultado, el principio y el final de la cadena quedan rotos.

Error nº 5: abuso del nivel ERROR.
Si cualquier desviación menor (tipo «el modelo devolvió 0 candidatos, mostramos un fallback») se registra como ERROR, las alertas de producción arderán constantemente. El equipo dejará de reaccionar a las alertas. Procura separar con honestidad: «WARN — fue raro, pero salimos adelante; ERROR — el escenario del usuario se rompió de verdad».

Error nº 6: esquemas de logs no alineados entre servicios.
Cuando en un servicio el campo se llama traceId, en otro correlation_id y en un tercero requestId, no te salva ningún sistema de logs. Es importante acordar un esquema único (como hicimos con LogEvent) y mantenerlo en todos los componentes: widget de la App, servidor MCP, agentes, ACP, webhooks. Así, construir dashboards de extremo a extremo e investigar incidentes será cuestión de minutos, no de días.

Error nº 7: intentar «optimizar» el tamaño de los logs eliminando campos clave.
A veces, persiguiendo ahorrar espacio, alguien decide: «quitemos user_id o flow, total, da igual». Luego, de repente, hay que responder «¿en qué usuarios falla más a menudo el checkout?» — y resulta que no hay información. Si hay que elegir qué eliminar, que sean payloads de texto largos (cuerpos de solicitudes/respuestas) y campos de depuración, no los identificadores y atributos de contexto clave.

Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION