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 :
- le journal métier, en base, qui dit ce qu’est devenu l’argent ;
- le journal d’appels, dans les logs techniques, qui dit quels appels ont été passés ;
- l’identifiant de corrélation, qui relie les deux.
Journal métier et logs techniques
Section intitulée « Journal métier et logs techniques »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.
Écritures hors transaction
Section intitulée « Écritures hors transaction »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 |
Le journal d’appels
Section intitulée « Le journal d’appels »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.
Champs d’une ligne
Section intitulée « Champs d’une ligne »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.
Le contexte ambiant
Section intitulée « Le contexte ambiant »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.
Corrélation de bout en bout
Section intitulée « Corrélation de bout en bout »L’en-tête X-Correlation-Id porte l’identifiant. Il accepte 64 caractères au plus parmi lettres,
chiffres, _ . : -.
Qui l’envoie
Section intitulée « Qui l’envoie »| 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.
Ce qu’en fait la plateforme
Section intitulée « Ce qu’en fait la plateforme »- Caddy pose l’en-tête s’il manque (
deploy/Caddyfile, vhostapi.) 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. - 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. - L’outbox le garde : colonne
outbox.correlation_id(migration022). Le worker rouvre le même contexte à la reprise de l’entrée, des secondes ou des heures plus tard. - Le journal métier l’enregistre dans
transaction_events.correlation_id. - Le webhook sortant le porte : le marchand reçoit
x-correlation-idavec 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.
Un paiement, ligne par ligne
Section intitulée « Un paiement, ligne par ligne »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.
Rédaction
Section intitulée « Rédaction »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ésrequestetresponseparsummarize.
| 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 }.
Fichiers
Section intitulée « Fichiers »| 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 |
Voir aussi
Section intitulée « Voir aussi »- Logs techniques — lire ces lignes depuis la console d’administration.
- Outbox & orchestration — ce que le worker exécute.
- Idempotence — les clés dont seule l’empreinte est écrite.
- Sécurité multi-tenant — ce qui ne doit jamais sortir.