1. Pourquoi vous avez besoin de journaux structurés dans ChatGPT App
Imaginez qu’un product manager vous écrive : « Les utilisateurs se plaignent que lors du choix d’un cadeau, une liste vide s’affiche parfois, et parfois le checkout plante. Peut-on corriger ça pour la démo de demain ? ». Vous avez :
- ChatGPT, qui appelle parfois votre App, parfois non.
- Un widget dans un bac à sable.
- Un serveur MCP qui interroge une base de produits externe et l’ACP.
- Des webhooks du prestataire de paiement.
Et seulement des journaux texte épars du type « something went wrong » quelque part sur le MCP et « order failed » quelque part sur le backend. En cas de requêtes parallèles, cela devient le chaos : impossible de comprendre quel journal se rapporte à quel utilisateur et à quelle requête.
Les journaux JSON structurés et un trace_id unique servent précisément à :
- voir toute la chaîne avec un seul identifiant : de la requête ChatGPT jusqu’au webhook "order.created" ;
- filtrer les journaux par service, outil, utilisateur, scénario ;
- répondre rapidement aux questions « pourquoi le checkout a planté » et « qu’a fait l’agent avant de commencer à halluciner ».
L’objectif est simple : faire en sorte que GiftGenius en production puisse être débogué et monitoré aussi bien qu’une application microservices classique.
2. Journaux en texte brut vs journaux structurés : pourquoi console.log("oy") ne suffit plus
En développement Next.js classique, beaucoup se contentent de journaux textuels : on imprime une phrase lisible par un humain et parfois quelques valeurs. Dans un service isolé, cela passe encore. Mais dans une pile ChatGPT App, ces journaux deviennent très vite de la bouillie.
Un journal texte, c’est juste une ligne dans un fichier ou une console. Par exemple :
console.error(`Error in suggestGifts for user ${userId}: ${error.message}`);
Quand ces messages se comptent en centaines de milliers, trouver « toutes les erreurs MCP dans le checkout avec userId=… hier » n’est déjà plus simple. Et construire automatiquement un dashboard sur les erreurs des outils — presque impossible.
Un journal structuré est un objet JSON où, en plus du texte du message, on trouve un ensemble de champs : niveau, temps, service, identifiants, contexte technique et métier. L’analogue de l’exemple précédent :
logger.error({
message: "suggest_gifts failed",
user_id: userId,
trace_id,
service: "mcp",
tool_name: "suggest_gifts",
error_message: error.message,
});
Chaque champ est indexé par le système de logs (ELK, Loki, Better Stack, Datadog, etc.), et l’on peut ensuite écrire des requêtes du type service="mcp" AND level="error" AND tool_name="suggest_gifts" ou simplement chercher par trace_id="...".
Pour plus de clarté — un petit tableau.
| Ce que l’on compare | Journaux textuels | Journaux structurés (JSON) |
|---|---|---|
| Parsing | Manuel, via regex | Automatique par champs |
| Recherche par champs | Requêtes regexp complexes | Expressions simples field=value |
| Agrégations et dashboards | Difficile, beaucoup de rustines | Trivial : count() , group by field |
| Enrichissement du contexte | Dans le texte du message | Avec de nouveaux champs sans changer le schéma |
| Corrélation des requêtes | Presque impossible avec des requêtes parallèles | Recherche classique par trace_id/request_id |
Dans le monde des applications LLM, où la moitié des problèmes ne sont pas des « erreurs 500 », mais « le modèle a appelé le mauvais outil », sans journaux structurés vous êtes littéralement aveugles.
3. Anatomie d’un journal JSON pour ChatGPT App
Convenons maintenant d’un « standard minimal » de log‑record que vous utiliserez à tous les niveaux de GiftGenius. Il n’est pas parfait, mais couvre 80 % des besoins.
Divisons les champs des journaux en plusieurs groupes.
Champs techniques
Les champs techniques sont nécessaires pour que les outils d’observabilité comprennent d’où vient l’entrée.
On peut les décrire par un type 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; // Brève description de l’événement
}
Il vaut mieux écrire timestamp au format ISO UTC ("2025-11-21T10:15:30.123Z"), ainsi les différents services peuvent être triés par temps sans jongler avec les fuseaux horaires. service et env aident à distinguer, par exemple, les journaux du MCP de production de ceux du widget en dev. C’est particulièrement pertinent si vous souhaitez plus tard adopter OpenTelemetry et utiliser les conventions communes service.name, service.version, etc.
Champs de corrélation
C’est le plus important pour cette leçon. Sans eux, vous ne pourrez pas relier les événements entre eux.
Ajoutons à notre interface :
interface CorrelationFields {
trace_id: string; // ID transversal de tout le scénario
span_id?: string; // (optionnel) ID d’une opération précise
parent_span_id?: string; // (optionnel) Opération parente
request_id?: string; // ID local d’une requête HTTP ou d’un appel d’outil
agent_run_id?: string; // ID d’exécution de l’agent (si présent)
tool_call_id?: string; // ID de l’appel d’un outil précis
checkout_session_id?: string; // ID de session ACP/paiement
}
trace_id est la vedette. Il doit être le même dans tous les journaux relatifs au scénario « L’utilisateur a demandé une recommandation de cadeau, nous avons choisi, créé une commande, reçu un webhook ». span_id et parent_span_id permettent ensuite de construire un « arbre des opérations » à la manière du traçage distribué, mais pour démarrer, trace_id et request_id suffisent souvent.
Contexte métier
Un journal technique sans contexte métier se transforme en « quelque chose s’est produit, quelque part, à un moment donné ». Nous devons comprendre quel utilisateur et à quelle étape du scénario ont été concernés.
Élargissons l’interface :
interface BusinessFields {
user_id?: string; // ID anonyme, PAS d’email
tenant_id?: string; // Organisation/compte, si B2B
flow?: string; // Ex.: "gift_recommendation" ou "checkout"
step?: string; // Ex.: "collect_requirements" ou "create_checkout"
}
Principe très simple : les identifiants peuvent être internes (UUID de votre BD), mais ne doivent pas contenir de PII (email, téléphone, nom complet). Nous en reparlerons dans la section sécurité.
Champs d’erreurs
Les erreurs méritent un traitement à part. On veut découper un journal d’erreur au moins en type, code et texte :
interface ErrorFields {
error_type?: "validation" | "upstream" | "timeout" | "system";
error_code?: string; // Statut HTTP, code BD ou votre enum
error_message?: string; // Bref et sûr
stack?: string; // Stack trace, attention au volume et à la PII
}
Important : error_message ne doit pas contenir de données sensibles (du type « failed for card 4111 1111 1111 1111 »). Préférez "payment provider declined card" et un code sûr.
Interface de journal complète
Assemblons le tout :
export interface LogEvent
extends BaseLogFields,
CorrelationFields,
BusinessFields,
ErrorFields {
// on garde de la marge pour des champs additionnels
[key: string]: unknown;
}
Vous pouvez utiliser cette interface sur le serveur MCP, le backend commerce et dans l’agent. Tous les services écriront alors des journaux dans le même format, et la corrélation deviendra une promenade plutôt qu’un parcours du combattant.
4. Un logger JSON minimal pour GiftGenius (serveur MCP)
Commençons par quelque chose de très minimaliste. Supposons que votre serveur MCP soit une application Node.js/TypeScript. Créons une utilitaire 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,
};
// On écrit le JSON sur stdout — il sera ensuite collecté par le système de logs
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),
};
Ce n’est ni Pino ni Winston, mais pour le cours, l’idée compte : tout est écrit en JSON avec des champs corrects.
Utilisons‑le maintenant dans le gestionnaire d’outil MCP suggest_gifts.
5. Journaliser un outil MCP : de l’entrée à la sortie
Supposons que vous ayez déjà un gestionnaire pour l’outil suggest_gifts, qui accepte les préférences de l’utilisateur et renvoie une liste de SKU. Ajoutons des journaux.
Disons que nous avons préalablement récupéré le trace_id depuis l’en‑tête HTTP x-trace-id (comment le mettre là — nous le verrons dans le bloc suivant sur la corrélation).
// 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;
}
}
Désormais, avec un seul trace_id, vous pourrez voir :
- que l’outil a bien été appelé ;
- combien de candidats ont été trouvés ;
- à quelle étape il a échoué.
Et nulle part n’apparaît l’email ou le nom de l’utilisateur — uniquement un user_id interne.
6. Où naît le trace_id dans ChatGPT App
Voyons où le trace_id doit naître. Il est important de comprendre qu’il n’est pas lié à une requête précise. trace_id est l’identifiant d’une opération métier. Il faut donc distinguer deux cas typiques :
Outil MCP « étroit »
C’est lorsque l’outil réalise une petite opération et renvoie immédiatement le résultat (sans UI interactive) :
- get_gifts_for_budget
- calculate_price
- save_lead, etc.
Dans ce cas, on considère volontiers : un appel d’outil MCP = une requête métier = un trace. Le trace_id de bout en bout naît côté MCP‑gateway / serveur MCP à l’entrée du tool‑call (ou bien est repris d’un contexte de traçage existant si vous utilisez OpenTelemetry). Ensuite ce trace_id est utilisé dans tous les appels internes (services REST, bases, files) et est écrit dans les journaux comme champ trace_id.
ChatGPT et l’Apps SDK n’interviennent pas ici : ils envoient juste un tool‑call JSON‑RPC, et le traçage démarre chez vous, en zone contrôlée.
Outil MCP « large » (retourne un widget)
Ici, l’outil ne termine pas l’opération métier jusqu’au bout, mais démarre une scène interactive : il renvoie un widget qui, dans le bac à sable, effectue des dizaines de requêtes fetch() (chargement de la liste des cadeaux, filtres, checkout, etc.).
Dans ce scénario, le traçage de bout en bout est différent :
- les opérations métiers principales vivent dans les requêtes HTTP du widget vers le backend ;
- par conséquent, chaque fetch() significatif du widget vers votre backend reçoit son propre trace_id, qui naît déjà dans le backend / gateway (le premier hop serveur pour ce fetch).
Ni ChatGPT, ni le widget ne sont « source de vérité » pour le trace_id : ils peuvent seulement transmettre dans la requête des identifiants auxiliaires (session_id, widget_id, user_id), tandis que la création et la gestion du trace_id se font côté serveur.
Outil MCP « étroit » : un trace par tool‑call
Voyons le flux pour un outil « étroit » sans widget :
sequenceDiagram
participant ChatGPT as ChatGPT / Agent
participant MCP as Serveur MCP
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 (facultatif avec trace_id)
Le pattern :
- à l’entrée du tool‑call dans le MCP, vous créez un trace (ou vous reprenez un existant depuis traceparent/x-trace-id) ;
- tout le parcours de ce tool‑call (appels de services, BD, caches) est journalisé avec le même trace_id ;
- les journaux n’impliquent pas le widget, car il n’y en a pas.
Ce que cela apporte :
- un « instantané » clair d’une opération : « outil MCP suggest_gifts → Gift API → Pricing API → réponse » ;
- un trace_id par appel d’outil.
Outil MCP « large » : widget et plusieurs traces
Voici le scénario GiftGenius où l’outil MCP renvoie un widget :
- ChatGPT appelle l’outil MCP, par exemple open_gift_widget.
- L’outil MCP forme la description du widget (layout, état initial) et la renvoie.
- Le widget est monté dans le bac à sable et commence à vivre sa vie :
- GET /api/gifts?budget=50&page=1
- GET /api/gifts?budget=50&filter=for_developers
- POST /api/checkout
- POST /api/save-lead
- Chaque requête HTTP arrive sur votre backend Next.js / gateway — et c’est là que vous créez un nouveau trace :
fetch #1 -> trace_id = T-501 (charger la première page de cadeaux)
fetch #2 -> trace_id = T-502 (appliquer le filtre « for_developers »)
fetch #3 -> trace_id = T-503 (créer le checkout)
...
Donc :
- l’outil MCP est « large » : sa tâche principale est d’ouvrir un widget, pas d’exécuter toute la chaîne métier ;
- la logique métier réelle (liste des cadeaux, choix du top cadeau, checkout) vit dans le backend, qui traite les fetch() du widget ;
- le groupe de requêtes fetch() unies par un même scénario métier possède un trace_id unique, que vous générez côté serveur à l’entrée de la requête HTTP.
Vous pouvez en plus transmettre dans chaque trace :
- session_id (ID de session ChatGPT, si disponible),
- widget_id,
- user_id,
- tool_run_id ou tout autre contexte.
Avec trace_id, vous examinez une opération précise (« checkout n° 3 ») ; avec session_id / widget_id, tout ce qui s’est passé dans un widget/une session.
7. Corrélation des requêtes : comment trace_id passe à travers l’App, le MCP, le widget et le backend
Passons à la partie la plus intéressante : comment faire transiter les identifiants nécessaires à travers toutes les couches : ChatGPT, serveur MCP, widget, backend commerce et webhooks.
Flux de requêtes avec trace_id (schéma du cas « large »)
Petit schéma de GiftGenius :
sequenceDiagram
participant ChatGPT as UI ChatGPT
participant MCP as Serveur MCP
participant Widget as Widget GiftGenius
participant Backend as Backend Next.js
participant ACP as Commerce API
participant WH as Gestionnaire de Webhook
ChatGPT->>MCP: tools.call open_gift_widget
MCP-->>ChatGPT: Description du widget (layout, config)
ChatGPT->>Widget: Rendu du widget dans le bac à sable
Widget->>Backend: GET /api/gifts (trace_id = T-501, naît dans le Backend)
Backend->>ACP: GET /gifts (x-trace-id = T-501)
ACP-->>Backend: 200 OK (trace_id = T-501)
Backend-->>Widget: JSON des cadeaux (trace_id = T-501 dans les logs)
Widget->>Backend: POST /api/checkout (trace_id = T-503, naît dans le 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: Journalise l’événement (trace_id = T-503)
À noter :
- dans ce schéma, le trace_id n’est pas généré par le widget ;
- il apparaît au point d’entrée de la requête HTTP vers votre backend (route handler Next.js, API‑gateway, etc.) ;
- ensuite, ce trace_id est propagé :
- dans les journaux du backend,
- dans l’en‑tête x-trace-id lors de l’appel de l’ACP,
- dans les webhooks, si l’ACP le renvoie/propague.
6.5. Générer et propager le trace_id dans le backend pour les appels du widget
Réécrivons l’exemple pour rendre explicite que le trace_id naît dans le backend, pas dans le widget.
// app/api/mcp/tools/call/route.ts (Backend Next.js, proxy vers 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 un trace_id vient de l'extérieur (par ex. gateway) — on l'utilise.
// Sinon — on en génère un nouveau à l’entrée du 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);
}
Et côté serveur MCP, nous lisons simplement cet en‑tête et utilisons le trace_id dans nos journaux (comme dans les exemples de la section 5).
Le widget peut même ignorer l’existence du trace_id — il lui suffit d’appeler /api/mcp/tools/call. Mais si vous souhaitez afficher ou journaliser des actions d’UI avec rattachement au traçage, vous pouvez renvoyer le trace_id dans la réponse et écrire, par exemple, service: "app-widget" dans vos journaux JSON (côté client ou via un SaaS d’analytics).
Exemple d’appel client MCP depuis le 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",
// On NE génère PAS trace_id ici — il naîtra dans le backend
},
body: JSON.stringify({ toolName, args }),
});
// Si le backend renvoie trace_id dans le corps, on peut le conserver :
const data = await res.json();
return data;
}
Si vous le souhaitez, vous pouvez étendre le handler backend pour qu’il ajoute le trace_id à la réponse JSON, et le widget pourra alors :
- journaliser des événements du type "service": "app-widget", "trace_id": "...",
- afficher des liens de trace pour les développeurs.
Mais le principe reste le même : la source du trace_id est le serveur, pas le widget.
Propager le trace_id vers l’ACP/commerce
Désormais, à l’intérieur de l’outil MCP create_checkout_session, nous appelons votre commerce API en transportant encore le trace_id dans les en‑têtes :
// 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;
}
Le backend commerce, lui aussi, lit x-trace-id et l’écrit dans ses journaux JSON. Ainsi, avec un seul trace_id, vous verrez :
- la requête HTTP entrante du widget dans le backend (où le trace est né) ;
- le proxy vers MCP (le cas échéant) ;
- l’appel interne create_checkout_session ;
- l’appel du commerce API ;
- la réponse du backend commerce ;
- et, s’il propage l’en‑tête, le webhook order.created.
8. Niveaux de logs : DEBUG, INFO, WARN, ERROR dans le contexte d’une application LLM
Les niveaux de logs aident à ne pas se noyer dans l’information. Dans une ChatGPT App, il est pratique de les interpréter ainsi :
- DEBUG — informations techniques détaillées, utiles en dev/staging. Par exemple, prompts tronqués, états intermédiaires de l’agent, réponses brutes d’API externes (sans PII). En production, il faut être très prudent.
- INFO — événements métiers normaux : « suggest_gifts succeeded, 10 candidats », « checkout session created », « webhook order.created processed ». Ces journaux peuvent rester activés en prod.
- WARN — quelque chose d’inhabituel s’est produit, mais le système a continué à fonctionner. Exemple : « fallback vers le catalogue en cache à cause d’un timeout upstream », « le modèle a renvoyé des arguments d’outil invalides, nouveau retry avec un schéma différent ».
- ERROR — échec avéré : le scénario utilisateur ne s’est pas terminé comme prévu. Exemple : « checkout API failed », « failed to persist order », « tool crashed with unhandled exception ».
Pour plus de confort, on peut ajouter un petit helper pour éviter d’écrire les chaînes à la main :
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 on log tout
}
Et appeler logger.debug seulement quand shouldLogLevel("debug") renvoie true.
C’est particulièrement dangereux en production d’écrire des logs DEBUG avec le prompt complet et la réponse du modèle : on peut y retrouver des mots de passe, des clés, ou n’importe quelle PII qu’un utilisateur a collée par erreur dans le chat.
9. Sécurité des journaux : PII‑scrub et secrets
Avec les journaux, on peut vite aller trop loin. Si vous écrivez « tout et n’importe quoi », vous :
- violerez des lois sur la protection des données ;
- faciliterez la vie d’un attaquant (les secrets et tokens peuvent être récupérés directement depuis les logs) ;
- aurez peur vous‑mêmes de donner l’accès au système de logs.
Principe simple : les journaux contiennent assez d’informations pour comprendre ce qui s’est passé, mais pas assez pour voler des données.
Bonnes pratiques :
- Journalisez user_id, pas l’email ni le téléphone. Si vous avez vraiment besoin de l’email dans les logs pour déboguer, journalisez son hash ou masquez‑le ("a***@gmail.com").
- N’écrivez jamais de tokens complets ("sk-..."), refresh tokens, client_secret, mots de passe. Si c’est indispensable — seulement les 4 premiers/derniers caractères et le type (« sk-***1234 »).
- Prudence avec tool_input et tool_output. Ils peuvent contenir tout ce qu’a écrit l’utilisateur. En production, ne les journalisez pas en entier, ou bien :
- journalisez uniquement les champs typés, déjà validés ;
- tronquez à une taille raisonnable et appliquez un scrub — masquage via regex (email, numéros de carte, etc.).
Exemple très simple de sanitiseur (très simplifié) :
export function sanitize(text: string): string {
return text
.replace(/sk-[a-zA-Z0-9]{20,}/g, "sk-***redacted***")
.replace(/\b\d{16}\b/g, "****-****-****-****"); // cartes
}
Et lors de la journalisation de l’entrée utilisateur :
logger.debug({
message: "raw_user_message",
service: "app-widget",
trace_id,
user_id,
raw: sanitize(userMessage),
});
Ce code est loin du niveau industriel, mais illustre bien l’idée : d’abord on nettoie, puis on journalise.
10. Pratique : événement gift_recommended pour GiftGenius
Faisons maintenant l’exercice : concevons l’événement de log gift_recommended, écrit lorsque GiftGenius choisit définitivement le « top cadeau » pour l’utilisateur.
L’événement doit permettre de répondre aux questions :
- quel utilisateur (ID interne) ;
- quel cadeau (SKU) ;
- dans quel scénario et à quelle étape ;
- quel trace_id, pour relier aux autres journaux.
Et ne doit pas contenir de PII ni de secrets.
Exemple :
{
"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"
}
Points importants :
- Nous journalisons user_id, pas l’email ni le nom ;
- SKU et prix — des données métiers normales, pas des PII ;
- reason_summary — un tag technique bref, pas la phrase complète de l’utilisateur ;
- il y a trace_id et agent_run_id, pour pouvoir voir quels outils l’agent a appelés en chemin.
Ce qu’il ne faut surtout pas journaliser :
- le texte complet de la réponse du modèle avec une « explication humaine » ;
- le prompt utilisateur (« je veux un cadeau pour une collègue, elle s’appelle X, son téléphone est…, son adresse est… ») ;
- toute donnée de paiement.
11. Exemples de journaux : tool‑call réussi et erreur ACP
Pour ancrer les idées — deux petits exemples JSON.
tools.call réussi sur 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
}
Avec un seul journal, on voit déjà :
- quel outil ;
- pour quel utilisateur ;
- selon quel scénario ;
- combien de temps cela a pris et combien de candidats ont été renvoyés.
Avec le trace_id, vous retrouverez facilement les journaux de l’UI et de l’agent pertinents pour la même requête.
Erreur 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"
}
Là encore, aucun numéro de carte, seulement un code d’erreur et un message sûr. Et toujours le même trace_id, ce qui vous permet de relier ce journal à gift_recommended et de comprendre à quel stade la chaîne a cassé.
12. Comment éviter que les journaux deviennent du bruit
La tentation est grande : « puisqu’on sait journaliser proprement, journalisons absolument tout ». Vous obtiendrez vite des gigas de bruit JSON, où les événements utiles se perdent.
Quelques conseils pratiques :
- Les journaux du type « je suis entré dans la fonction X » sans information supplémentaire sont peu utiles. Mieux vaut journaliser des événements significatifs : début/fin de scénario, appel d’API externe, transition d’étape de workflow, erreurs.
- Pour des opérations fréquentes (par ex., requêtes au catalogue), activez un sampling : journaliser 1 requête sur N en entier, et les autres — seulement en cas d’erreur.
- En production, laissez DEBUG désactivé (ou très sélectif). Si vous journalisez prompts/réponses, faites‑le de façon limitée et avec scrub.
Nous parlerons des métriques et des SLO dans la prochaine leçon, mais il est déjà essentiel de comprendre : les journaux ne servent pas qu’au débogage, ils sont le fondement de l’observabilité de toute la pile ChatGPT.
Souvenez‑vous du product manager du début avec la « liste vide » et le checkout qui plante ? Avec le schéma de journaux décrit, vous trouveriez en quelques minutes toutes les requêtes avec le trace_id voulu, regarderiez suggest_gifts (combien de candidats l’outil a renvoyés, à quelle étape il a échoué) et les journaux "checkout failed" avec error_code du prestataire de paiement. On n’est plus dans une « enquête sur bouillie de logs », mais dans un scénario clair « de la requête au webhook ».
Au final, une bonne pile de journalisation pour ChatGPT App, ce n’est pas « on écrit quelque chose sur stdout », mais :
- des points de naissance corrects du trace_id (dans le MCP‑gateway/serveur pour les outils « étroits » et à l’entrée du backend pour les fetch() du widget dans les scénarios « larges ») ;
- un trace_id unique de bout en bout App → MCP → commerce → webhooks pour chaque appel métier pertinent ;
- un schéma commun de journaux JSON (service, env, user_id, flow, step, tool_name, etc.) ;
- un traitement soigneux des PII et secrets (scrub, masquage, DEBUG limité en production) ;
- des niveaux de logs pertinents et l’absence de bruit.
Avec cette base, les autres outils d’observabilité (métriques, SLO, alertes) deviennent bien plus utiles et vous aident non seulement à « collecter des logs », mais à réellement piloter la qualité et la stabilité de votre ChatGPT App.
13. Erreurs typiques avec les journaux structurés et la corrélation
Erreur n° 1 : absence d’un trace_id unique à travers tous les services.
Cas classique : le MCP‑gateway génère un ID, le backend commerce — un autre, les webhooks ne connaissent pas la corrélation, et dans les journaux du widget, trace_id n’apparaît pas. Résultat : la corrélation devient une recherche manuelle « les heures ont l’air de coïncider ». La bonne approche — générer le trace_id dans des points d’entrée contrôlés (serveur MCP pour les outils « étroits », backend/gateway — pour les fetch() du widget) et le faire passer toutes les frontières : en‑têtes HTTP, champs JSON, contexte de l’agent.
Erreur n° 2 : tenter de générer le trace_id dans le widget et le considérer comme « vérité ».
Cela semble parfois logique : « faisons un crypto.randomUUID() directement dans le widget React et mettons‑le dans les en‑têtes ». Problème : le trace_id vit alors côté client et peut ne pas correspondre au traçage serveur réel (OpenTelemetry, gateway, autres services). Il est bien plus fiable que le trace_id apparaisse là où vous contrôlez tout le chemin serveur : backend Next.js, API‑gateway ou serveur MCP. Le widget peut, si besoin, seulement le lire et le journaliser.
Erreur n° 3 : journaliser PII et secrets « pour faciliter le débogage ».
Au début du développement, il est « très pratique » d’écrire dans le journal tout le corps du prompt, les tokens, numéros de cartes et emails. Quelques mois plus tard, c’est une bombe à retardement : l’accès aux journaux devient toxique, l’audit de sécurité pose des questions désagréables, et vous avez peur de montrer ne serait‑ce qu’une capture d’écran d’erreur. Dès le départ, mettez en place le scrub et ne journalisez pas ce que vous devrez supprimer dans l’urgence demain.
Erreur n° 4 : journaux textuels sans structure dans une des couches.
Parfois, l’équipe fait de super journaux JSON dans MCP et commerce, mais dans le widget, elle laisse console.log("step 1", data). Résultat : début et fin de chaîne restent disjoints.
Erreur n° 5 : abuser du niveau ERROR.
Si le moindre écart mineur (du type « le modèle a renvoyé 0 candidats, on affiche un fallback ») est journalisé en ERROR, les alertes de prod seront toujours rouges. L’équipe finit par ne plus réagir aux alertes du tout. Essayez de séparer honnêtement : « WARN — étrange, mais on a géré ; ERROR — le scénario utilisateur est réellement cassé ».
Erreur n° 6 : schémas de journaux non harmonisés entre services.
Quand dans un service le champ s’appelle traceId, dans un autre correlation_id, et dans un troisième requestId, aucun système de logs ne sauvera la mise. Il est crucial de s’accorder sur un schéma unique (comme nous l’avons fait avec LogEvent) et de s’y tenir dans tous les composants : widget App, serveur MCP, agents, ACP, webhooks. La construction de dashboards de bout en bout et l’investigation d’incidents deviennent alors des tâches de minutes, pas de jours.
Erreur n° 7 : « optimiser » la taille des logs en supprimant des champs clés.
Parfois, pour économiser de l’espace, quelqu’un propose : « retirons user_id ou flow, c’est du détail ». Puis on vous demande soudain « chez quels utilisateurs le checkout plante le plus souvent ? » — et l’information manque. Si vous devez retirer quelque chose, visez les payloads textuels longs (corps de requêtes/réponses) et les champs de debug, pas les identifiants ni les attributs contextuels essentiels.
GO TO FULL VERSION