# Remplacer 10 000 lignes de regex par une machine à états configurable > Retour sur la réécriture d'un outil CLI d'analyse de logs pour GE Healthcare. L'ancien outil enchaînait des regex fragiles. On l'a remplacé par un parseur à machine à états piloté par des fichiers de configuration XML, avec un mini-interpréteur d'expressions arithmétiques. Date : 15/11/2016 Auteurs : Aurélien N., Raphael P. Tags : Ruby, CLI, Architecture, Parsing --- On travaille depuis quelques mois avec General Electric Healthcare sur un outil interne : ATT, pour Access Time Tool. C'est un programme en ligne de commande qui analyse les logs des équipements médicaux (appareils de radiographie, fluoroscopie) pour extraire des métriques de performance : temps d'acquisition, durée des cycles, détection de reset, ce genre de choses. Les résultats finissent dans une base MySQL et des fichiers CSV pour le reporting. L'outil existait déjà. Le problème, c'est comment il était construit. Des regex dispersées partout dans le code, des `if/else` imbriqués pour gérer les cas particuliers, des formats de log qui changeaient selon le modèle de machine. Chaque nouvelle variante de log demandait une intervention dans le code. GE nous a demandé de reprendre ça de zéro, en gardant les mêmes fonctionnalités mais en rendant le système maintenable. ## Le problème des regex dispersées Pour comprendre pourquoi l'ancien outil était fragile, il faut voir à quoi ressemble un log de ces machines. C'est du texte brut, une ligne par événement, avec un timestamp et un message. Le format change selon le type de machine et la version du firmware. L'ancien outil parsait ça avec des regex codées en dur. Pour chaque métrique à extraire, quelqu'un avait écrit une regex spécifique, souvent sans comprendre toutes les variantes du format. Quand un nouveau modèle de machine arrivait, ou qu'une mise à jour firmware changeait un label, il fallait trouver la bonne regex dans le code, la modifier sans casser les autres, et tester sur des fichiers de logs réels qu'on n'avait pas toujours sous la main. Le nombre de regex n'était pas le problème. Le problème, c'est qu'elles étaient mélangées avec la logique de traitement. La connaissance du format de log et la logique de calcul des métriques étaient dans les mêmes fichiers. Impossible de modifier l'un sans risquer de casser l'autre. ## L'architecture : séparer la connaissance du format Notre approche : sortir toute la connaissance du format de log dans des fichiers de configuration XML, et construire un moteur de parsing générique qui interprète ces fichiers. Le code Ruby ne sait rien du format des logs de GE. Il sait lire un XML qui décrit des patterns, des règles et des résultats, et il applique ça ligne par ligne. La CLI est construite sur Thor (le framework de commandes en ligne pour Ruby, le même qu'utilise Rails). Trois commandes : `start` pour lancer l'analyse, `validate` pour vérifier un fichier de config contre le schéma XSD, et `convert` pour migrer les anciens formats de config. ## La machine à états : Pattern, Rule, Result Le coeur du système est le `LogProcessor`, une machine à états qui parcourt le fichier de log ligne par ligne. Il manipule trois concepts : Un **Pattern** match une ligne de log. Il peut utiliser une correspondance exacte de chaîne (`match`), une regex avec capture de groupes (`regex` + `regex_ids`), ou des valeurs statiques (`flag_ids` + `flag_values`). La correspondance exacte est testée d'abord comme filtre rapide, la regex ensuite pour extraire les paramètres. ```ruby class Pattern def match(line) # Filtre rapide par sous-chaîne (O(n) sur la ligne) return nil if pattern_config.match && !line.include?(pattern_config.match) # Puis regex si nécessaire (plus coûteux) return nil if pattern_config.regex && line !~ pattern_config.regex Match.new(self) end end ``` Une **Rule** définit une transition dans la machine à états. Elle a un `start_pattern` (qui l'active) et un `stop_pattern` optionnel (qui la clôt). Quand une Rule est active, tous les patterns matchés sont accumulés dans un buffer de paramètres. Quand la Rule suivante s'active, les résultats de la Rule courante sont collectés et le buffer est vidé. Un **Result** calcule une métrique à partir des paramètres accumulés. Il prend un timestamp de début, un timestamp de fin, et une expression arithmétique qui peut référencer les paramètres extraits. La boucle principale du `LogProcessor` fait 60 lignes. Pour chaque ligne du fichier : ```ruby def process_log_line(log_line, line_number) # Matcher tous les patterns contre la ligne new_matches = patterns.map .compact return if new_matches.empty? # Extraire le timestamp timestamp = parse_timestamp(log_line) new_matches.each # Chercher si une nouvelle Rule s'active next_active_rule = rules.find &.start if next_active_rule # Collecter les résultats de la Rule sortante if active_rule && next_active_rule.keep_previous_rule? @results += active_rule.collect_results(matches_by_key, @parameters, persistent_parameters) end reset_matches @active_rule = next_active_rule end # Sauvegarder les matches dans le buffer new_matches.each do |match| unless match.pattern.keep_previous? && @matches_by_key.has_key?(match.key) @matches_by_key[match.key] = match new_match_parameters = match.parameters(log_line) @parameters.merge!(new_match_parameters) # Les paramètres persistants survivent entre les Rules new_persistent = new_match_parameters.select @persistent_parameters.merge!(new_persistent) end end end ``` L'opérateur `&.` (safe navigation, arrivé avec Ruby 2.3) évite les checks `nil` partout. Le `keep_previous?` sur les patterns empêche un second match d'écraser le premier, ce qui est utile quand la même regex apparaît plusieurs fois dans un log (on veut garder la première occurrence). ## Le mini-interpréteur d'expressions Les résultats sont calculés par des expressions arithmétiques définies dans la config XML. Par exemple, `($END$ - $BEGIN$) / 1000` calcule une durée en secondes à partir des timestamps début/fin en millisecondes. Les variables entre `$` sont substituées par les valeurs extraites des patterns. On aurait pu faire un `eval` Ruby. On a préféré écrire un vrai interpréteur, pour les mêmes raisons que le [moteur de règles du baby-foot](/blog/moteur-regles-baby-foot-ast-ruby) : pas d'exécution de code arbitraire sur des fichiers de config qui viennent du réseau. L'interpréteur utilise RLTK (Ruby Language Toolkit) pour le lexer et le parser. Le lexer tokenise, le parser construit un AST, et un évaluateur récursif (pattern Visitor) le parcourt : ```ruby # Lexer — tokenise "2 + (10 * $VALUE$)" class Lexer < RLTK::Lexer rule(/\+/) rule(/-/) rule(/\*/) rule(/\//) rule(/\(/) rule(/\)/) rule(/([0-9]+\.)?[0-9]+/) rule(/\$[a-zA-Z]([a-zA-Z0-9_])*\$/) rule(/\s/) end # AST nodes class Expression < RLTK::ASTNode; end class Number < Expression value :value, Float end class Variable < Expression value :name, String end class Add < BinaryExpression; end class Mul < BinaryExpression; end ``` C'est un interpréteur minimal : quatre opérateurs arithmétiques, des parenthèses, des nombres et des variables. Mais il gère la précédence des opérateurs (multiplication avant addition) correctement, ce qu'un `eval` avec substitution de chaînes ne fait pas toujours bien. ## La configuration XML : le vrai livrable Le fichier `run_config.xml` est le vrai produit du projet. C'est lui qui contient la connaissance métier, validé contre un schéma XSD : ```xml ``` Quand un ingénieur GE veut analyser un nouveau type de log, il écrit un XML. Il ne touche pas au code Ruby. Il définit les patterns à chercher, les règles de transition, les métriques à calculer. Il valide son XML contre le XSD avec `att validate`, il lance `att start`, et les résultats tombent dans la base MySQL. ## La métaprogrammation Ruby au service de la config La couche de configuration utilise deux modules Ruby qui méritent d'être mentionnés. Le premier, `DynamicAttributes`, génère des getters/setters à partir de déclarations : ```ruby class MainConfig < Base attribute :database_host, String attribute :database_port, Integer, default: 3306 attribute :logs_location, String attribute :programs, Array, of: RunConfig end ``` Chaque appel à `.attribute` fait un `class_eval` qui crée les accesseurs et enregistre les métadonnées (type, valeur par défaut, optionalité) dans une variable de classe. Ces métadonnées sont ensuite utilisées par le second module, `XmlSerialization`, pour lire et écrire du XML automatiquement. Le second module utilise les Refinements de Ruby 2.3 pour ajouter des méthodes de conversion aux types (String, Integer, Boolean, Regexp, etc.) sans polluer l'espace global. C'est une feature qu'on n'avait jamais utilisée avant ce projet, et c'est exactement le cas d'usage : du monkey-patching scopé, actif uniquement dans les fichiers qui l'importent. ## La découverte de fichiers : plus compliqué qu'il n'y paraît Les logs de GE sont stockés dans une arborescence de répertoires organisée par date et par cellule (une cellule = une machine). Les fichiers peuvent être en texte brut, compressés en gzip, dans des archives zip, ou dans des zip contenant des gzip. On a vu les quatre cas en production. Le `LogFilesLocator` gère tout ça avec une lecture par chunks pour les gros fichiers (certains logs font 20 Mo+) et un système d'Enumerator Ruby pour éviter de charger un fichier entier en mémoire. Le flag `unique` dans la config permet de ne garder que le log le plus récent quand plusieurs fichiers correspondent au même pattern, ce qui évite de retraiter des données déjà en base. ## Le bilan L'outil est en version 1.8.0-beta0. GE l'utilise en interne pour analyser les logs de leurs équipements de radiographie. Les ingénieurs ajoutent des types de logs en écrivant des XML, pas en modifiant du Ruby. Le schéma XSD attrape les erreurs de configuration avant l'exécution. Le vrai gain, c'est la séparation des préoccupations. La connaissance du format de log est dans les XML. La logique de parsing est dans le moteur Ruby. La logique métier post-traitement (détection de reset, modes d'acquisition) est dans des modules dédiés. Quand un format change, on modifie un XML. Quand le moteur a un bug, on corrige le Ruby sans toucher aux configs. Quand la logique métier évolue, on ajoute un module de post-traitement. C'est moins spectaculaire qu'un baby-foot connecté ou une app mobile. Mais c'est le genre de projet qui montre que Ruby est un bon choix pour du tooling industriel : expressif pour la métaprogrammation, solide pour le parsing, et avec un écosystème de gems (Thor, Nokogiri, RLTK, mysql2) qui couvre les besoins sans forcer à réinventer la roue.