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.
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 :
def process_log_line(log_line, line_number)
# Matcher tous les patterns contre la ligne
new_matches = patterns.map { |pattern| pattern.match(log_line) }.compact
return if new_matches.empty?
# Extraire le timestamp
timestamp = parse_timestamp(log_line)
new_matches.each { |match| match.timestamp = timestamp }
# Chercher si une nouvelle Rule s'active
next_active_rule = rules.find { |rule| rule.can_start?(new_matches) }&.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 { |key,_|
persistent_parameter?(key)
}
@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 : 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 :
# Lexer — tokenise "2 + (10 * $VALUE$)"
class Lexer < RLTK::Lexer
rule(/\+/) { :PLUS }
rule(/-/) { :SUB }
rule(/\*/) { :MUL }
rule(/\//) { :DIV }
rule(/\(/) { :LPAREN }
rule(/\)/) { :RPAREN }
rule(/([0-9]+\.)?[0-9]+/) { |t| [:NUMBER, t.to_f] }
rule(/\$[a-zA-Z]([a-zA-Z0-9_])*\$/) { |t| [:IDENT, t[1..-2]] }
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 :
<LOG_TYPE name="CYCLER" log_file_regex="cycler_.*\.log"
time_stamp_regex="(\d{2}):(\d{2}):(\d{2})\.(\d{3})"
time_stamp_regex_ids="hour,min,sec,msec">
<PATTERNS>
<PATTERN key="INIT_START"
match="Cycler is being initialized"/>
<PATTERN key="INIT_END"
regex="Checkbox CyclerResetNum: (\d+)"
regex_ids="RESET_NUM"/>
<PATTERN key="ACQ_START"
match="StartAcquisition"/>
<PATTERN key="ACQ_END"
regex="StopAcq.*kVp=(\d+).*mAs=(\d+)"
regex_ids="KVP,MAS"/>
</PATTERNS>
<RULES>
<RULE name="INITIALIZATION"
start_pattern="INIT_START" stop_pattern="INIT_END">
<RESULT action_name="init_time"
start_pattern_key="INIT_START"
stop_pattern_key="INIT_END"
computed_value="($END$ - $BEGIN$) / 1000"/>
</RULE>
<RULE name="ACQUISITION"
start_pattern="ACQ_START" stop_pattern="ACQ_END"
keep_previous_rule="true">
<RESULT action_name="acquisition_time"
start_pattern_key="ACQ_START"
stop_pattern_key="ACQ_END"
computed_value="$END$ - $BEGIN$"/>
</RULE>
</RULES>
<PERSISTENT_PARAMETERS keys="CYCLER_VERSION,DL_VERSION"/>
<ENTRY_POINT key="INIT_START"/>
</LOG_TYPE>
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 :
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.