đš Cette Ă©dition est relativement longue car beaucoup dâimage, votre boĂźte mail risque de la tronquer. Je vous conseille de cliquer sur le titre ci-dessus pour lâouvrir dans votre navigateur web. âïž
Hello les petits Biscuits !
Bienvenue sur la 49Úme édition de Ruby Biscuit.
Vous ĂȘtes maintenant 605 abonnĂ©s đ„ł
Bonne lecture.
Vous lisez Ruby Biscuit, la newsletter Rails de Capsens. Sâabonner gratuitement
OĂč en est lâenquĂȘte ? Un virement sortant de 15 000 ⏠validĂ© un mardi Ă 22h13 nâest jamais parti. Le client lâapprend le lendemain matin. La page de confirmation sâĂ©tait affichĂ©e, la base montre le paiement figĂ© en statut
processing, et aucune alerte nâa sonnĂ©. On cherche ce qui est arrivĂ© Ă ce virement dans la nuit. Seul matĂ©riel Ă disposition : les logs Rails par dĂ©faut, non structurĂ©s et non centralisĂ©s.
Quand on ouvre ces logs pour comprendre, on trouve quarante lignes par requĂȘte, des rendus de partials, des Parameters tronquĂ©s, et pour notre virement, lâenfilement du job Ă 22h13 et son exĂ©cution Ă 22h39. AprĂšs cette ligne, aucune trace : ni erreur, ni confirmation de fin. Le virement est mort quelque part dans la nuit sans quâon sache ni quand ni pourquoi.
Câest le sujet de ce deuxiĂšme article : produire des logs qui racontent quelque chose. Dans le premier Ă©pisode, on a choisi les indicateurs Ă suivre ; avant de brancher les outils qui les exploiteront (ce sera le troisiĂšme), il faut sâassurer que la matiĂšre premiĂšre est bonne. Un log est un tĂ©moignage Ă©crit par votre application pour quelquâun qui la relira dans trois mois, de mauvaise humeur, possiblement un soir dâincident. Ăcrivez pour ce lecteur-lĂ .
Cette sĂ©rie suit un mĂȘme incident, en fil rouge, au fil des techniques abordĂ©es. Les encadrĂ©s « âȘ Ce que vous auriez vu » montrent les lignes de log ou les Ă©crans que le bon outillage aurait produits cette nuit-lĂ , et qui nâont jamais existĂ© faute de prĂ©paration. Vous pouvez les sauter : le reste se lit comme un tutoriel autonome.
Quoi logguer : les événements qui comptent
Le rĂ©flexe courant consiste Ă logguer ce qui est facile (les requĂȘtes HTTP, que Rails journalise tout seul) et Ă oublier ce qui est important : les Ă©vĂ©nements mĂ©tier. Une connexion rĂ©ussie aprĂšs trois Ă©checs, un changement dâIBAN, une Ă©lĂ©vation de droits, un virement initiĂ© puis annulĂ©. Ce sont ces Ă©vĂ©nements quâon cherche pendant un incident, et quâun rĂ©gulateur ou un auditeur demandera.
Voici lâheuristique que jâutilise : si un Ă©vĂ©nement devait apparaĂźtre dans la chronologie dâun rapport dâincident, il mĂ©rite une ligne de log. Pour une application financiĂšre, cela couvre au minimum lâauthentification (succĂšs et Ă©checs), les opĂ©rations sur les moyens de paiement, les changements de droits et de donnĂ©es sensibles, et les actions dâadministration.
Reste Ă choisir le niveau. Un niveau mal choisi rend les logs soit muets, soit assourdissants.
Les niveaux de log classent chaque message par gravité, du plus anodin au plus grave. Rails en propose cinq.
debug: le dĂ©tail de mise au point, utile en dĂ©veloppement.info: une information de routine, comme un Ă©tat courant (connexion, virement initiĂ©).warn: un Ă©vĂ©nement qui pourrait poser problĂšme (troisiĂšme Ă©chec de connexion, appel externe lent).error: un Ă©vĂ©nement qui a probablement causĂ© un problĂšme et sur lequel quelquâun doit se pencher.fatal: lâapplication ne peut plus fonctionner.
En pratique, dans un contrĂŽleur ou un service :
Rails.logger.info("[payments] transfer initiated user_id=#{current_user.id} transfer_id=#{transfer.id} amount_cents=#{transfer.amount_cents}")
Rails.logger.warn("[auth] failed login attempt=3 user_id=#{user.id}")Deux habitudes dans ces exemples : un prĂ©fixe entre crochets qui identifie le domaine (on filtre ensuite dâun simple grep), et des paires clĂ©=valeur plutĂŽt que des phrases. « Lâutilisateur 42 a initiĂ© le virement 1337 » est agrĂ©able Ă lire une fois ; user_id=42 transfer_id=1337 se recherche, sâagrĂšge et se compare des milliers de fois.
Le piĂšge du debug en production
Voici le cĆur de notre enquĂȘte. En production, on tourne gĂ©nĂ©ralement avec config.log_level = :info. ConsĂ©quence directe : tout appel Ă Rails.logger.debug construit dâabord sa chaĂźne de caractĂšres, puis la jette sans rien Ă©crire. Le message est produit, puis simplement jetĂ©.
Note : ce gaspillage a un remĂšde, la forme Ă bloc. Rails.logger.debug { "coĂ»teux #{calcul}" } nâĂ©value son contenu que si le niveau debug est actif, donc jamais en production. Pratique pour les logs coĂ»teux, mais attention : cela ne change rien Ă notre incident. La ligne reste filtrĂ©e dans les deux formes, seul le niveau la ferait apparaĂźtre.
Câest exactement ce qui a effacĂ© notre preuve. Le job de virement, en Ă©chouant, faisait ceci :
def perform(transfer_id)
transfer = Transfer.find(transfer_id)
PaymentProvider.execute!(transfer)
rescue PaymentProvider::Error => e
Rails.logger.debug("[payments] provider refused transfer_id=#{transfer_id} #{e.message}")
raise # on relance pour laisser Sidekiq gérer le réessai
endEn dĂ©veloppement, tout se voit et le code paraĂźt irrĂ©prochable. En production, ce mĂȘme message part au niveau debug, qui est filtrĂ© : le rejet du prestataire a bien eu lieu, mais il nâen reste aucune trace.
Il suffit de logguer Ă un niveau visible en production. Un rejet de paiement mĂ©rite au moins un warn, avec le contexte nĂ©cessaire Ă lâenquĂȘte.
rescue PaymentProvider::Error => e
Rails.logger.warn("[payments] provider refused transfer_id=#{transfer_id} code=#{e.code} reason=#{e.reason}")
raise
endâȘ Ce que vous auriez vu Avec le bon niveau, cette ligne serait apparue dans les logs, horodatĂ©e Ă 22h39 :
[payments] provider refused transfer_id=8843 code=PAY-503 reason="cut_off_window_closed". Le prestataire clĂŽture sa fenĂȘtre de remise des virements Ă 22h30 (remise classique, pas dâinstant payment), et le job sâest prĂ©sentĂ© vingt-six minutes trop tard. Cette ligne nâa jamais existĂ©. Le lecteur la voit, lâĂ©quipe ne la verra jamais.
Ă retenir : choisissez le niveau dâun log en vous demandant si quelquâun aura besoin de le lire un soir dâincident. Si oui, debug ne suffit pas.
Des erreurs qui aident lâenquĂȘte
Lâinformation qui manquera le soir de lâincident, câest souvent un rescue silencieux qui lâa effacĂ©e. Deux outils changent la donne : une hiĂ©rarchie dâerreurs Ă vous, et des messages qui embarquent leur contexte.
# app/errors/payment_error.rb
class PaymentError < StandardError
def initialize(msg = nil, code: "PAY-000")
@code = code
super(msg)
end
attr_reader :code
end
class InsufficientFundsError < PaymentError
def initialize(wallet_id:, requested_cents:)
super("insufficient funds wallet_id=#{wallet_id} requested_cents=#{requested_cents}", code: "PAY-012")
end
endLa hiĂ©rarchie permet de rattraper finement (rescue PaymentError couvre toute la famille) et le code dâerreur, unique et stable, devient une clĂ© de voĂ»te : câest lui quâon cherche dans les logs, lui quâon met dans la rĂ©ponse au support (« donnez-nous le code affichĂ© Ă lâĂ©cran »), lui quâon compte pour repĂ©rer une anomalie rĂ©currente. Le message, lui, embarque les identifiants nĂ©cessaires Ă lâenquĂȘte.
Note : le code dâerreur peut ĂȘtre montrĂ© Ă lâutilisateur, le message jamais tel quel. « insufficient funds wallet_id=87 » renseigne un attaquant sur votre modĂšle de donnĂ©es ; lâĂ©cran affichera plutĂŽt « Une erreur est survenue » suivie dâun code de rĂ©fĂ©rence. Le dĂ©tail vit dans les logs, la rĂ©fĂ©rence circule.
Ce qui ne doit pas sây trouver, et ce quâon doit garder
Passons au sujet qui fùche, et qui occupe une bonne partie de mes journées de RSSI.
Les logs sont des fichiers texte copiĂ©s, agrĂ©gĂ©s, transmis Ă des services tiers, conservĂ©s des mois et lus par toute une Ă©quipe. Toute donnĂ©e qui sây glisse Ă©chappe au contrĂŽle dâaccĂšs soigneusement construit dans votre application. Le RGPD sâapplique aux logs comme au reste de lâapplication. Un mot de passe ou un IBAN qui sây retrouve est une fuite, tout simplement.
La liste noire pour une application financiĂšre : mots de passe et tokens (Ă©videmment), mais aussi emails, IBAN et numĂ©ros de carte, documents dâidentitĂ©, adresses, et toute donnĂ©e de santĂ© ou assimilĂ©e. La bonne pratique consiste Ă rĂ©fĂ©rencer plutĂŽt que citer : user_id=42 permet de tout retrouver en base, oĂč les contrĂŽles dâaccĂšs sâappliquent, sans exposer qui que ce soit dans le fichier.
Rails fournit le mĂ©canisme central : le filtrage des paramĂštres. Tout ce qui matche est remplacĂ© par [FILTERED] dans les logs de requĂȘtes.
# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [
# valeurs générées par défaut par Rails (la liste s'étoffe selon la version)
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc,
# ajouts propres Ă une application financiĂšre
:iban, :bic, :card
]La premiĂšre sĂ©rie est gĂ©nĂ©rĂ©e par dĂ©faut par Rails ; elle Ă©volue dâune version Ă lâautre, les plus rĂ©centes ajoutant par exemple :cvv et :cvc. On la complĂšte avec les champs propres Ă son mĂ©tier. Le matching se fait par sous-chaĂźne : :passw couvre password, password_confirmation et user[password].
Note : ce filtre couvre les paramĂštres de requĂȘtes, et rien dâautre. Ni les chemins dâURL, ni les noms de fichiers, ni vos propres appels Ă Rails.logger. Un GET /documents/8412/download dont le nom de fichier est « justificatif-domicile-DUPONT.pdf » Ă©crit ce nom en clair dans les access logs, hors de portĂ©e du filtre. Le rĂ©flexe qui protĂšge : rĂ©fĂ©rencer par identifiant (document_id=8412), jamais par nom. Le filtrage automatique est un filet, pas une absolution.
Dernier point, la conservation, oĂč deux forces tirent en sens inverse. La minimisation du RGPD pousse Ă effacer vite ; dâautres textes, comme DORA ou la lutte contre le blanchiment, imposent de garder de quoi enquĂȘter. Pas de chiffre magique, donc, mais un arbitrage : la recommandation de la CNIL sur la journalisation donne un point dâĂ©quilibre courant de six mois Ă un an pour les journaux techniques ordinaires, au-delĂ seulement avec une justification.
Note (pour les Ă©quipes soumises Ă la finance rĂ©glementĂ©e) : on lit souvent que « les logs se conservent cinq ans » au titre de la LCB-FT. Câest un raccourci. Lâarticle L.561-12 du code monĂ©taire et financier vise la finalitĂ© de la donnĂ©e, pas son support : un journal qui constitue la trace dâune opĂ©ration entre dans le pĂ©rimĂštre des cinq ans, un log de rendu de template nây entre pas. La durĂ©e se dĂ©cide ligne par ligne, selon ce que chacune prouve, et cet arbitrage se documente.
Lograge : des logs qui se traitent en machine
Il reste un problĂšme de forme. Voici ce que Rails Ă©crit par dĂ©faut pour une seule requĂȘte en production :
Started GET "/transfers/1337" for 203.0.113.7 at 2026-08-05 22:13:41 +0200
Processing by TransfersController#show as HTML
Parameters: {"id"=>"1337"}
Rendered transfers/_summary.html.erb (Duration: 1.2ms)
Rendered transfers/show.html.erb within layouts/application (Duration: 12.1ms)
Completed 200 OK in 89ms (Views: 32.1ms | ActiveRecord: 41.2ms)Six lignes par requĂȘte, illisibles dĂšs que mille requĂȘtes sâentremĂȘlent. Les outils de centralisation veulent lâinverse : une requĂȘte, une ligne, des champs structurĂ©s. Câest ce que fait Lograge, qui condense le tout en un Ă©vĂ©nement JSON :
# config/initializers/lograge.rb
Rails.application.configure do
config.lograge.enabled = true
config.lograge.formatter = Lograge::Formatters::Json.new
config.lograge.custom_payload do |controller|
{
request_id: controller.request.request_id,
user_id: controller.respond_to?(:current_user) ? controller.current_user&.id : nil
}
end
endEt le résultat :
{
"method":"GET",
"path":"/transfers/1337",
"status":200,
"duration":89.0,
"db":41.2,
"view":32.1,
"controller":"TransfersController",
"action":"show",
"request_id":"9f80b7...",
"user_id":42
}Le custom_payload mĂ©rite votre attention : câest lui qui transforme un log technique en log exploitable. Le request_id, gĂ©nĂ©rĂ© par Rails pour chaque requĂȘte, permet de relier entre elles toutes les lignes dâune mĂȘme requĂȘte ; ajoutez config.log_tags = [:request_id] pour quâil apparaisse aussi sur vos propres appels Ă Rails.logger, et lâarchĂ©ologie devient une simple recherche. Le user_id, lui, permet de reconstituer le parcours dâun utilisateur. Retenez ce champ : câest de lui que dĂ©pendra, au prochain article, notre capacitĂ© Ă isoler un compte parmi des milliers.
Note : on retrouve dans ce JSON nos connaissances du premier article, duration, db et view. Les compteurs quâon y bricolait avec un subscriber deviennent de simples agrĂ©gations sur ces champs : câest tout lâintĂ©rĂȘt dâun format structurĂ©.
Note : en condensant chaque requĂȘte sur une ligne, Lograge retire au passage les temps de rendu par partial. Pour traquer une vue lente, ce sera dans lâoutillage de mĂ©triques de lâarticle 3, plus dans les logs.
Le trou noir asynchrone : le cycle de vie des jobs
Nos logs de requĂȘtes sont maintenant propres. Mais notre virement nâest pas mort dans une requĂȘte, il est mort dans un job Sidekiq, et câest lĂ que la piste sâĂ©tait interrompue. Deux problĂšmes sây cachent.
Un job est une tĂąche que lâapplication confie Ă lâarriĂšre-plan plutĂŽt que de la traiter pendant la requĂȘte : elle la dĂ©pose dans une file dâattente et rĂ©pond aussitĂŽt Ă lâutilisateur. Un processus sĂ©parĂ©, le worker, dĂ©pile ces jobs et les exĂ©cute un Ă un, Ă part du trafic web. Sidekiq est la bibliothĂšque qui joue ce rĂŽle dans lâĂ©cosystĂšme Rails, en rĂ©essayant les jobs qui Ă©chouent. Un job qui Ă©choue trop de fois est dĂ©clarĂ© mort et mis de cĂŽtĂ©.
Le premier : le request_id ne franchit pas la frontiĂšre asynchrone. La requĂȘte HTTP qui dĂ©clenche le virement porte un identifiant, le job qui lâexĂ©cute nâen sait rien. On propage lâidentifiant de corrĂ©lation avec un middleware, une couche qui intercepte chaque job Ă lâentrĂ©e et Ă la sortie, pour que le job hĂ©rite du fil de la requĂȘte qui lâa créé :
# config/initializers/sidekiq.rb
class CorrelationClientMiddleware
def call(_worker, job, _queue, _redis)
job["correlation_id"] ||= RequestStore.store[:request_id]
yield
end
end
class CorrelationServerMiddleware
def call(_worker, job, _queue)
RequestStore.store[:request_id] = job["correlation_id"]
yield
end
endLe second, plus grave dans notre cas : la mort dâun job ne laisse aucune trace par dĂ©faut. Notre virement a Ă©tĂ© rĂ©essayĂ©, puis abandonnĂ©, en silence. Encore faut-il maĂźtriser la politique de rĂ©essai, car le dĂ©faut de Sidekiq (vingt-cinq tentatives sur prĂšs de trois semaines) ne convient ni Ă un paiement ni Ă une opĂ©ration Ă fenĂȘtre. On la rend explicite, et on journalise lâabandon quand il survient :
class TransferJob
include Sidekiq::Job
sidekiq_options queue: :default, retry: 4
# quatre réessais espacés de 20 minutes : on laisse au prestataire
# le temps de se rétablir, sans traßner au-delà du raisonnable
sidekiq_retry_in { |_count, _exception| 20.minutes.to_i }
sidekiq_retries_exhausted do |job, exception|
Rails.logger.error(
"[payments] transfer job dead transfer_id=#{job['args'].first} " \
"code=#{exception.respond_to?(:code) ? exception.code : 'unknown'}"
)
end
def perform(transfer_id)
# ...
end
endAvec cette configuration, le job tenté à 22h39 est repris à 22h59, 23h19, 23h39, puis abandonné peu avant minuit. Le sidekiq_retries_exhausted écrit alors le certificat de décÚs qui nous manquait.
La leçon tient en une phrase : la mort dâun job est un Ă©vĂ©nement mĂ©tier, elle mĂ©rite sa propre ligne de log plutĂŽt quâune place dans une file technique.
Note (le piĂšge du dead set) : on pourrait croire que le dead set de Sidekiq, oĂč atterrissent les jobs morts, tient lieu de mĂ©moire. Il est plafonnĂ©, en nombre comme en durĂ©e, et Ă©vince les plus anciens quand il dĂ©borde : une rafale de jobs morts efface les prĂ©cĂ©dents. Si une configuration hĂ©ritĂ©e a un jour abaissĂ© ce plafond pour « Ă©conomiser la mĂ©moire Redis », votre rĂ©serve de preuves se rĂ©duit Ă une fenĂȘtre de quelques minutes. Câest exactement ce qui a fini dâeffacer notre virement.
âȘ Ce que vous auriez vu Avec le death handler en place, une seule ligne aurait clos lâaffaire, horodatĂ©e Ă 23h59 :
[payments] transfer job dead transfer_id=8843 code=PAY-503. De lâenfilement Ă 22h13 jusquâĂ lâabandon, la vie entiĂšre du virement aurait tenu dans une recherche surtransfer_id=8843.
En deux mots
Bien logguer tient en quelques disciplines : choisir les Ă©vĂ©nements (le mĂ©tier autant que la technique), Ă©crire au bon niveau et sous forme exploitable, surveiller ce qui sâĂ©chappe et ce quâon garde, et ne jamais laisser un job mourir sans un mot. Rien de spectaculaire, et câest prĂ©cisĂ©ment le genre de travail quâon remarque seulement le jour oĂč il manque.
Reste que nos logs, aussi propres soient-ils, sâaccumulent dans un fichier sur un serveur. Cela ne fait ni une recherche rapide, ni une courbe, ni une alerte. Or notre enquĂȘte bute maintenant sur deux questions que le virement seul ne peut pas rĂ©soudre : pourquoi a-t-il attendu vingt-six minutes avant de sâexĂ©cuter, et que faisaient ces GET /documents en volume anormal au mĂȘme moment ? Le prochain article sort les logs du serveur pour commencer Ă compter.
â InĂšs, Responsable de la SĂ©curitĂ© des SystĂšmes dâInformation chez Capsens
Pour aller plus loin
Niveaux de gravité des logs : table
LogSeverity, Google Cloud LoggingJobs, files dâattente et workers : Background Jobs & Queueing, Heroku Dev Center
Sidekiq : documentation officielle et wiki sur la gestion des erreurs
Lograge : dépÎt et README
Filtrage des paramĂštres :
ActionDispatch::Http::FilterParameters, API RailsConservation des journaux : recommandation de la CNIL sur la journalisation
Journalisation sous DORA : article 12 du rÚglement délégué (UE) 2024/1774
Conservation LCB-FT : article L.561-12 du code monétaire et financier
