14 - Le filtre Ruby : quand les autres ne suffisent pas
Ce que tu vas apprendre
- La syntaxe du filtre Ruby (inline et fichier externe)
- L'API Event pour lire, écrire et supprimer des champs
- Des cas d'usage concrets : calcul de duree, logique conditionnelle complexe, aplatissement de structures
- Les impacts sur la performance
- Quand le filtre Ruby est justifie et quand il ne l'est pas
Prerequisites
- Connaitre les filtres de base (Grok, Mutate, Date)
- Des bases en Ruby aident, mais le code reste simple
Le dernier recours
Je dis toujours a mes collegues : si tu utilises le filtre Ruby, c'est que tu n'as pas trouve le bon plugin. Et souvent, c'est vrai. Mais parfois, aucun plugin ne fait exactement ce dont tu as besoin. Tu veux calculer la différence entre deux timestamps, tronquer un champ a 500 caractères, ou faire un lookup dans un hash en mémoire. Les plugins standards ne couvrent pas tous ces cas.
Le filtre Ruby te donne acces a un vrai langage de programmation a l'intérieur du pipeline. C'est puissant, mais ca a un coût en performance.
Syntaxe inline
filter {
ruby {
code => '
level = event.get("level")
if level == "ERROR" || level == "FATAL"
event.set("alert", true)
event.set("[@metadata][notify]", "slack")
end
'
}
}
Le code Ruby est entre guillemets simples. L'objet event represente l'événement courant. Tu lis avec event.get, tu ecris avec event.set.
L'API Event
Lire un champ
ruby# Champ de premier niveau
level = event.get("level")
# Champ imbrique
city = event.get("[geoip][city_name]")
# Metadata
beat = event.get("[@metadata][beat]")
Écrire un champ
ruby# Creer ou ecraser un champ
event.set("processed", true)
# Champ imbrique
event.set("[metrics][p99]", 234.5)
# Metadata
event.set("[@metadata][target_index]", "logs-critical")
Supprimer un champ
rubyevent.remove("temporary_field")
event.remove("[nested][field]")
Ajouter un tag
rubyevent.tag("slow_request")
Annuler un événement
rubyevent.cancel
L'événement est supprime du pipeline. Il ne passera pas aux filtres suivants et ne sera pas envoye a l'output. Utile pour filtrer les événements qu'on ne veut pas indexer.
Cas d'usage 1 : calculer une duree
Tu as deux timestamps dans l'événement : le début et la fin d'une requête. Tu veux la duree en millisecondes.
filter {
ruby {
code => '
start_str = event.get("request_start")
end_str = event.get("request_end")
if start_str && end_str
start_time = Time.parse(start_str)
end_time = Time.parse(end_str)
duration_ms = ((end_time - start_time) * 1000).round
event.set("duration_ms", duration_ms)
end
'
}
}
Aucun plugin standard ne fait ce calcul. Le filtre date parse un timestamp, mais ne calcule pas de différence.
Cas d'usage 2 : tronquer un champ
Les champs de message trop longs font exploser l'index Elasticsearch. Limite a 1000 caractères :
filter {
ruby {
code => '
msg = event.get("message")
if msg && msg.length > 1000
event.set("message", msg[0..999])
event.set("message_truncated", true)
end
'
}
}
Cas d'usage 3 : aplatir un objet imbrique
Tu recois un JSON imbrique et tu veux aplatir les champs pour Elasticsearch :
json{
"request": {
"headers": {
"content-type": "application/json",
"authorization": "Bearer xxx"
}
}
}
Tu veux request_headers_content_type au lieu de [request][headers][content-type] :
filter {
ruby {
code => '
def flatten_hash(hash, prefix = "")
result = {}
hash.each do |key, value|
new_key = prefix.empty? ? key : "#{prefix}_#{key}"
new_key = new_key.gsub("-", "_")
if value.is_a?(Hash)
result.merge!(flatten_hash(value, new_key))
else
result[new_key] = value
end
end
result
end
request = event.get("request")
if request.is_a?(Hash)
flat = flatten_hash(request, "request")
flat.each { |k, v| event.set(k, v) }
event.remove("request")
end
'
}
}
Cas d'usage 4 : filtrer les événements
Supprimer les health checks du pipeline :
filter {
ruby {
code => '
url = event.get("url") || ""
method = event.get("http_method") || ""
if url == "/health" || url == "/ready" || url == "/metrics"
event.cancel
end
'
}
}
Tu pourrais aussi faire ca avec un conditionnel Logstash + drop {}, mais le filtre Ruby permet une logique plus complexe (regex, listes, etc.).
Le bloc init
Si tu as besoin d'initialiser des variables une seule fois (pas a chaque événement) :
filter {
ruby {
init => '
require "set"
@ignore_paths = Set.new(["/health", "/ready", "/metrics", "/favicon.ico"])
@sensitive_fields = ["password", "token", "secret", "authorization"]
'
code => '
# Filtrer les paths a ignorer
url = event.get("url") || ""
event.cancel if @ignore_paths.include?(url)
# Masquer les champs sensibles
@sensitive_fields.each do |field|
if event.get(field)
event.set(field, "[REDACTED]")
end
end
'
}
}
Le bloc init s'exécuté une seule fois au chargement du pipeline. Les variables d'instance (@variable) persistent entre les événements. Le bloc code s'exécuté pour chaque événement.
Fichier externe
Pour du code plus long, mets-le dans un fichier :
ruby# logstash/scripts/enrich.rb
def register(params)
@lookup = {
"api-users" => "team-backend",
"api-orders" => "team-backend",
"api-payments" => "team-payments",
"web-frontend" => "team-frontend"
}
end
def filter(event)
service = event.get("service")
if service && @lookup.key?(service)
event.set("team", @lookup[service])
else
event.set("team", "unknown")
end
return [event]
end
Utilisation dans le pipeline :
filter {
ruby {
path => "/usr/share/logstash/scripts/enrich.rb"
}
}
Le fichier doit définir au minimum la méthode filter(event) qui renvoie un tableau d'événements. register(params) est l'équivalent de init pour les fichiers externes.
Monte le dossier scripts dans Docker :
yamllogstash:
volumes:
- ./logstash/scripts/:/usr/share/logstash/scripts/:ro
Structure du projet :
logstash/
├── config/
│ ├── logstash.yml
│ └── pipelines.yml
├── pipeline/
│ └── main.conf
├── patterns/
│ └── custom
└── scripts/
└── enrich.rb
Performance
Le filtre Ruby est lent. Pas catastrophiquement lent, mais sensiblement plus lent que les plugins natifs.
| Opération | Plugin natif | Filtre Ruby |
|---|---|---|
| Renommer un champ | mutate: ~0.01 ms | ruby: ~0.1 ms |
| Convertir un type | mutate: ~0.01 ms | ruby: ~0.1 ms |
| Logique conditionnelle | if/else: ~0.01 ms | ruby: ~0.15 ms |
Le facteur est d'environ 10x. Pour 1000 événements par seconde, c'est invisible. Pour 50 000 par seconde, ca s'additionne.
Regles :
- Si un plugin natif fait le job, utilise-le
- Si tu as besoin de Ruby, minimise le code dans
codeet mets les initialisations dansinit - Evite les
requiredanscode(mets-les dansinit) - Ne fais pas d'I/O (HTTP, fichier) dans le filtre Ruby, c'est un bottleneck
Quand Ruby est justifie
- Calcul de duree entre deux timestamps
- Logique conditionnelle complexe (plus de 3 niveaux d'imbrication)
- Manipulation de structures de donnees (aplatir, pivoter, regrouper)
- Lookup dans un dictionnaire en mémoire
- Masquage de donnees sensibles avec une logique non triviale
- Filtrage d'événements avec des regles complexes
Quand Ruby n'est PAS justifie
- Renommer un champ (mutate rename)
- Convertir un type (mutate convert)
- Ajouter un champ statique (mutate add_field)
- Parser du JSON (filtre json)
- Geolocaliser une IP (filtre geoip)
Si tu te retrouves a reecrire un plugin existant en Ruby, arrêté et utilise le plugin. Sur paltemps.fr, on a une regle : tout filtre Ruby doit avoir un commentaire qui explique pourquoi un plugin natif ne suffit pas.
Résumé
- Le filtre Ruby donne acces a un vrai langage de programmation dans le pipeline
- L'API Event :
event.get,event.set,event.remove,event.cancel,event.tag - Le bloc
inits'exécuté une fois,codes'exécuté par événement - Pour du code long, utilise un fichier externe avec
path - Ruby est ~10x plus lent que les plugins natifs, utilise-le avec parcimonie
- Justifie toujours l'usage de Ruby : si un plugin natif fait le job, préféré-le
Precedent : 13 - Filtres KV, JSON, XML | Suivant : 15 - Aggregate et Metrics