Aller au contenu

Traçabilité

Un paiement traverse plusieurs processus et plusieurs tiers : la boutique, le reverse proxy, l’API, l’outbox, le worker, Titréo, le PSP, puis la boutique de nouveau par webhook. Cette page décrit ce qui permet de reconstituer cette chaîne après coup.

Trois mécanismes, qui se complètent :

  1. le journal métier, en base, qui dit ce qu’est devenu l’argent ;
  2. le journal d’appels, dans les logs techniques, qui dit quels appels ont été passés ;
  3. l’identifiant de corrélation, qui relie les deux.

Ce sont deux choses distinctes. Les confondre mène à chercher au mauvais endroit.

Journal métier Logs techniques
Ce qu’il dit Ce qui est arrivé à un paiement, en français Ce qu’a fait le programme, appel par appel
Où il vit Table Firebird transaction_events (migration 021) Sortie standard des conteneurs, collectée par Alloy, rangée dans Loki
Qui écrit Les use cases, par le port EventJournal pino (loggerOptions) et le journal d’appels (logCall)
Garantie Écrit dans la transaction métier : ne peut pas diverger de l’état Aucune : une ligne peut manquer
Rétention EVENTS_RETENTION_DAYS, 90 jours par défaut, purge par le worker cleanup 720 h (30 jours), deploy/observability/loki.yml
Lecture GET /v1/admin/events, GET /v1/sessions/:id/events GET /v1/admin/logs et les routes voisines
Disponibilité Toujours Seulement si la collecte est activée (tep obs on)

Le port du journal métier est api/src/application/ports/event-journal.ts. Le catalogue des événements est api/src/application/events/journal-events.ts : un fichier pour toutes les phrases, des types nommés domaine.sujet.fait qui ne changent jamais.

Certains faits n’ont pas de transaction métier à laquelle se rattacher : l’effet a déjà eu lieu chez un tiers. Ils passent par BestEffortJournal (api/src/application/events/best-effort-journal.ts). L’échec d’écriture est signalé dans les logs, jamais propagé : un journal indisponible ne transforme pas un débit réussi en erreur.

Type Fait
gift.balance.checked Solde d’une carte cadeau consulté, dans une session ou non
gift.balance.check_failed Consultation de solde refusée
gift.direct.debited Débit direct chez l’émetteur, hors session (miroir Shopify)
gift.direct.refunded Recrédit direct, hors session
gift.direct.debit_failed Échec d’un débit direct
gift.direct.refund_failed Échec d’un recrédit direct
session.client_token.issued Jeton d’accès émis pour le navigateur de l’acheteur
session.manifest.served Page de paiement chargée, avec les moyens proposés

api/src/infrastructure/observability/call-log.ts écrit une ligne par appel, de quelque côté qu’il vienne, toujours sous la même forme. Le champ kind dit de quel appel il s’agit.

kind Appel Écrit par
http_in Requête HTTP reçue par l’API api/src/http/plugins/call-log.ts
provider_call Opération demandée à un PSP ou à Titréo (authorize, debit…) api/src/infrastructure/observability/timed-providers.ts
http_out Requête HTTP émise vers Titréo ou Stripe observability/logged-fetch.ts, titreo/titreo-logged-fetch.ts, psp/stripe-call-log.ts
webhook_in Webhook reçu d’un PSP, ignorés et doublons compris api/src/http/routes/webhooks.routes.ts
webhook_out Webhook envoyé au marchand api/src/infrastructure/webhooks/http-webhook-sender.ts
outbox Tentative de traitement d’une entrée d’outbox api/src/infrastructure/workers/outbox-poller.ts
email_out Courrier remis au relais SMTP api/src/infrastructure/email/logged-email-sender.ts

provider_call et http_out décrivent le même appel à deux niveaux : le premier au niveau métier (l’opération, son issue), le second au niveau transport (l’adresse, le statut HTTP). Titréo annule et rembourse par le même DELETE : seule la ligne provider_call dit lequel des deux était demandé.

Adyen n’a pas de ligne http_out : ses appels n’apparaissent que par leur ligne provider_call. Un appel Stripe qui n’obtient aucune réponse n’en a pas non plus, le SDK ne publiant son événement qu’après une réponse.

Type CallRecord :

Champ Contenu
kind Type d’appel, voir ci-dessus
provider stripe, adyen, titreo, merchant, smtp
operation Nom de l’opération (POST /v1/sessions/:id/refund, debit, auth_token…)
method, url Méthode et adresse, sans paramètres de requête
route Gabarit de route (/v1/sessions/:id), pour les requêtes reçues
status Statut HTTP, ou null sans réponse
outcome ok, refused (l’autre partie a répondu non) ou error (pas de réponse exploitable)
durationMs Durée arrondie à la milliseconde
attempt, maxAttempts Rang de l’essai, pour l’outbox
errorCode, errorMessage Message borné à 500 caractères
providerRequestId Identifiant chez le prestataire : req_… chez Stripe, numeroChrono ou numeroAutorisation chez Titréo
idempotencyKeyHash Empreinte de la clé d’idempotence, jamais la clé
request, response Résumés censurés, bornés à 2 Ko

S’y ajoutent des champs propres à un type : ip, userAgent, origin, requestBytes pour http_in ; entryId, nextAttemptAt, deadLettered pour outbox ; to (adresse masquée) pour email_out.

Le niveau de la ligne suit l’issue : info pour ok, warn pour refused, error pour error.

logCall est sans effet tant qu’aucun logger n’est branché (setCallLogSink). Le serveur le branche sur app.log (api/src/http/server.ts), chaque worker sur son logger (api/src/infrastructure/observability/worker-logger.ts). Dans les tests et les scripts, rien n’est écrit.

Le journal d’appels remplace les deux lignes par requête de Fastify (disableRequestLogging: true dans api/src/http/app.ts). Les sondes (/metrics, /health, /health/live, /health/ready, /healthz) ne sont pas écrites tant qu’elles répondent en dessous de 400.

correlationId, merchantId, sessionId et legId ne sont pas dans CallRecord. Ils viennent du contexte ambiant (api/src/infrastructure/observability/request-context.ts, un AsyncLocalStorage), que le mixin de pino reporte sur chaque ligne (observability/logger.ts).

Champ Posé par
correlationId http/plugins/request-context.ts à l’entrée HTTP ; outbox-poller.ts à la reprise d’une entrée
merchantId, actorType, actorId http/plugins/auth.ts une fois l’appelant authentifié ; webhooks.routes.ts pour un webhook PSP (actorType: 'psp')
sessionId http/plugins/call-log.ts, sur les routes /v1/sessions/:id… et /v1/admin/sessions/:id… ; outbox-poller.ts
legId outbox-poller.ts, depuis l’entrée d’outbox

Le correlationId change à chaque requête. sessionId est ce qui retrouve tous les appels d’un paiement sans passer par lui.

Hors développement, le niveau est écrit en toutes lettres ("level":"info") et non en nombre (30) : c’est lui qui sert d’étiquette dans Loki. En développement, pino-pretty reste en place.

L’en-tête X-Correlation-Id porte l’identifiant. Il accepte 64 caractères au plus parmi lettres, chiffres, _ . : -.

Brique Règle Fichier
sdk-js Un identifiant par requête, ou correlationId du client pour tout regrouper sdk-js/src/http.ts
sdk-php Idem, correlationId de la Configuration sdk-php/src/Http/HttpClient.php
Composant checkout Un identifiant par instance ; attribut correlation-id pour fournir le sien checkout-sdk/src/components/checkout.ts
checkout-page Un identifiant par appel serveur checkout-page/lib/api.ts
Consoles Un identifiant par appel console-admin/lib/api.ts, console-merchant/lib/api.ts
App Shopify La clé d’idempotence de l’opération, à défaut un tirage epay-shopify-app/app/services/epay-issuer.server.ts
Module PrestaShop ps-<empreinte boutique>-<cart|order|bo>-<id> TitreoEpay::correlationId() dans titreoepay.php

Les SDK exposent l’identifiant sur leurs erreurs (ApiError.correlationId, ApiException::$correlationId). C’est la référence à donner au support.

  1. Caddy pose l’en-tête s’il manque (deploy/Caddyfile, vhost api.) et l’ajoute à sa ligne d’accès (log_append correlationId). La ligne d’accès et les lignes de l’API partagent le même identifiant.
  2. L’API le reprend tel quel s’il tient en 64 caractères, en tire un sinon (http/plugins/request-context.ts), et le renvoie dans la réponse.
  3. L’outbox le garde : colonne outbox.correlation_id (migration 022). Le worker rouvre le même contexte à la reprise de l’entrée, des secondes ou des heures plus tard.
  4. Le journal métier l’enregistre dans transaction_events.correlation_id.
  5. Le webhook sortant le porte : le marchand reçoit x-correlation-id avec la notification (http-webhook-sender.ts). Le module PrestaShop le relit (TitreoEpay::incomingCorrelationId()).

CORS : X-Correlation-Id est dans allowedHeaders et dans exposedHeaders (api/src/http/app.ts). Le navigateur peut l’envoyer et le lire.

Carte cadeau puis carte bancaire, jusqu’à la notification de la boutique. Les notes donnent la ligne écrite à chaque étape.

sequenceDiagram
    participant N as "Navigateur (composant checkout)"
    participant C as "Caddy"
    participant A as "API"
    participant O as "outbox (Firebird)"
    participant W as "worker-outbox"
    participant T as "Titréo"
    participant P as "PSP (Stripe)"
    participant B as "Boutique du marchand"

    N->>C: "POST /v1/sessions/:id/legs/gift, X-Correlation-Id"
    Note over C: "ligne d'accès, avec correlationId"
    C->>A: "requête relayée"
    A->>O: "enqueue DEBIT_GIFT, correlation_id"
    A-->>N: "réponse, x-correlation-id"
    Note over A: "http_in"
    W->>O: "claim de l'entrée DEBIT_GIFT"
    Note over W: "contexte rouvert : correlationId, sessionId, legId"
    W->>T: "POST /api/AuthToken, si aucun jeton en cache"
    Note over W: "http_out titreo auth_token"
    W->>T: "PUT /api/Card"
    T-->>W: "result 0"
    Note over W: "http_out titreo debit, provider_call titreo debit, outbox DEBIT_GIFT"
    N->>C: "POST /v1/sessions/:id/submit, X-Correlation-Id"
    C->>A: "requête relayée"
    A->>O: "enqueue AUTHORIZE_PSP, correlation_id"
    Note over A: "http_in"
    W->>P: "authorize"
    P-->>W: "autorisation"
    Note over W: "http_out stripe, provider_call stripe authorize, outbox AUTHORIZE_PSP"
    W->>P: "capture"
    P-->>W: "capture confirmée"
    Note over W: "http_out stripe, provider_call stripe capture, outbox CAPTURE_PSP"
    W->>B: "POST webhook signé, x-correlation-id"
    Note over W: "webhook_out merchant session.completed, outbox MERCHANT_WEBHOOK"

Le composant checkout envoie le même identifiant pour tout le parcours. Le worker reprend celui de la requête qui a enfilé l’entrée, et les entrées qu’il enfile à son tour (CAPTURE_PSP, MERCHANT_WEBHOOK) en héritent. Voir Outbox & orchestration pour l’enchaînement des actions.

Rien de sensible ne sort dans une ligne. Deux niveaux de censure :

  • les chemins censurés de pino (REDACT_PATHS, observability/logger.ts) ;
  • la censure récursive redactDeep (observability/redact.ts), appliquée aux résumés request et response par summarize.
Donnée Traitement
Numéro de carte cadeau (carte, numSupport, ticketNumCarte, numCarte, barcode) Réduit aux 4 derniers caractères (••••1234)
Carte dans une ligne provider_call Champ cardLast4
CVV, CVC [REDACTED]
Clé Titréo (key), jeton (token, jwt, bearer) [REDACTED]
client_secret Stripe, paymentMethod [REDACTED]
Réponse de AuthToken Jamais écrite : elle est le jeton. Seul le nom d’enseigne figure dans la requête
Clé d’idempotence Remplacée par idempotencyKeyHash, 12 caractères d’un SHA-256
Adresses Sans paramètres ni fragment (stripQuery)
Adresse e-mail d’un destinataire s•••@exemple.fr
Corps d’un courrier, corps d’un webhook sortant Jamais écrits

Les lignes d’accès de Caddy gardent l’adresse complète dans leur fichier. À la lecture, l’API n’en rend que le chemin (sanitizeAccessLog dans api/src/infrastructure/logs/loki-log-store.ts) : certaines routes des consoles reçoivent un jeton par ce canal.

Un résumé est borné à 2 Ko. Au-delà, il est remplacé par { tronque, taille, apercu }.

Fichier Rôle
api/src/infrastructure/observability/call-log.ts logCall, CallRecord, summarize, fingerprint, stripQuery
api/src/infrastructure/observability/request-context.ts Contexte ambiant
api/src/infrastructure/observability/logger.ts Options pino partagées, mixin, chemins censurés
api/src/infrastructure/observability/redact.ts redactDeep, serializeEventData
api/src/infrastructure/observability/timed-providers.ts Décorateurs des registres PSP et cartes cadeaux
api/src/infrastructure/observability/logged-fetch.ts fetch journalisé, générique
api/src/infrastructure/titreo/titreo-logged-fetch.ts Lecture d’un appel Titréo
api/src/infrastructure/psp/stripe-call-log.ts Branchement sur l’événement response du SDK Stripe
api/src/http/plugins/call-log.ts Ligne http_in, sessionId dans le contexte
api/src/application/events/journal-events.ts Catalogue du journal métier
api/src/application/events/best-effort-journal.ts Journal hors transaction
deploy/Caddyfile En-tête posé dès le reverse proxy