Normalisation : SQL et HTTP
Ce sujet existe en version animée : voir l’étage Normalisation dans le synoptique →La normalisation est la deuxième étape du pipeline. Elle transforme les SpanEvent bruts en NormalizedEvent en extrayant un template (requête paramétrée ou pattern d'URL) et les valeurs concrètes des paramètres.
Pourquoi ne pas utiliser sqlparser ?
Le crate sqlparser est un parseur SQL complet qui construit un AST. Nous avons choisi un tokenizer maison à la place :
- Taille du binaire : un parseur complet et ses grammaires de dialectes ajoutent du code au binaire release. Perf Sentinel cible < 15 Mo au total.
- Poids des dépendances : sqlparser amène des crates supplémentaires et augmente le temps de compilation.
- Pas de dialecte requis : sqlparser nécessite de spécifier un dialecte SQL (PostgreSQL, MySQL, etc.). Notre tokenizer produit un template pour tous les dialectes car il ne remplace que les littéraux : il n'a jamais besoin de comprendre la structure de la requête. Le moteur nommé par
db.systemn'ajuste que les règles de guillemets (chaînes MySQL entre guillemets doubles, échappements par barre oblique inverse), et un moteur inconnu reçoit la lecture qui protège les données. - Performance : un parseur complet construit un AST que nous jetterions immédiatement. Notre tokenizer en une seule passe traite l'entrée en O(n) sans structure de données intermédiaire.
- Simplicité : quelques centaines de lignes de code vs un parseur complet et ses grammaires de dialectes.
Le compromis est documenté dans Limites : les délimiteurs et mots-clés sur lesquels le tokenizer agit (guillemets, $, chiffres, IN) doivent être en ASCII, le contenu des littéraux peut être en UTF-8 quelconque, et il ne réalise pas d'analyse sémantique. Il prend en charge les CTEs, les identifiants entre guillemets doubles, les chaînes dollar-quoted PostgreSQL et les instructions CALL.
Cette propriété (ne jamais comprendre la structure de la requête) suffit pour toutes les étapes du pipeline. Chaque détecteur (n_plus_one, redundant, fanout, …) et le classifieur sanitizer_aware raisonnent sur la forme de la trace (nombre d'occurrences, variance temporelle, ordonnancement des spans, scope d'instrumentation ORM) et sur l'empreinte de la requête, jamais sur sa grammaire interne. Une analyse SQL structurelle ne serait rentable que si Perf Sentinel pivotait vers de l'analyse statique de requête isolée (une autre catégorie de produit), et même là un plan EXPLAIN vaut mieux que re-parser le texte journalisé.
Le tokenizer est aussi total. Il produit toujours un template, construit au mieux, même sur du SQL tronqué ou de dialecte inconnu (cf. la gestion des littéraux non terminés), là où un vrai parseur rejette une entrée qu'il ne sait pas analyser. Comme le SQL des traces est ce qu'un driver a bien voulu journaliser, un parseur strict aurait de toute façon besoin de ce tokenizer comme repli.
Tokenizer SQL : machine à états en une seule passe
normalize_sql() traite la requête octet par octet à travers six états. normalize_sql_for(query, db_system) fait tourner la même machine, en lisant " comme un guillemet de chaîne quand db_system vaut mysql ou mariadb. Le pipeline passe le db.system du span, mysql-stat passe mysql et pg-stat passe postgresql :
| État | Déclencheur (entrée) | Action | Déclencheur (sortie) |
|---|---|---|---|
| Normal | Défaut / fin de littéral | Accumule dans le template | ', ", `, $$/$tag$, ou chiffre isolé |
| InString | Guillemet ouvrant ', ou " sur MySQL/MariaDB | Accumule dans current_value, saute l'octet qui suit un \ qui échappe | Même guillemet, ni doublé ni échappé |
| InNumber | Chiffre isolé | Accumule chiffres/point, puis une suite hexadécimale après 0x ou un exposant (e, signe facultatif, chiffres) | Non-chiffre, deuxième point, ou fin de la suite hexadécimale ou de l'exposant |
| InDoubleQuote | Guillemet double ouvrant ", autres moteurs | Laisse passer dans le template (identifiant préservé) | Guillemet double fermant " |
| InBacktick | Accent grave ouvrant ` (identifiant MySQL) | Laisse passer dans le template (identifiant préservé) | Accent grave fermant ` |
| InDollarQuote | $$ ou $tag$ | Accumule le corps dans current_value, émet un ? | $$ / $tag$ correspondant |
Le rôle d'échappement de la barre oblique inverse dans InString est fixé à l'ouverture de la chaîne. Les moteurs de STANDARD_STRING_SYSTEMS (PostgreSQL, CockroachDB, Vertica, SQL Server, Oracle, DB2, SQLite, H2, HSQLDB, Derby, Trino, Presto, Teradata) suivent la norme et n'échappent que dans une chaîne E'...', un E ou e juste avant le guillemet sans octet d'identifiant devant lui. Tout autre moteur, y compris MySQL, MariaDB et un moteur non nommé, échappe dans toutes les chaînes. Pour un moteur inconnu, c'est le côté qui protège les données : un échappement manqué ferme la chaîne trop tôt et fait fuiter la suite dans le template, alors qu'un échappement en trop ne fait que masquer plus que nécessaire. Le paramètre garde l'échappement tel qu'écrit.
Optimisation batch push_str
Au lieu de pousser les caractères un par un avec template.push(b as char), le tokenizer suit un index normal_start :
// À l'entrée dans InString ou InNumber :
if i > normal_start {
template.push_str(&query[normal_start..i]);
}
// Au retour en Normal :
normal_start = i;
// À la fin de l'entrée (toujours en Normal) :
template.push_str(&query[normal_start..len]);Cela regroupe les séquences contiguës en état Normal en un seul appel push_str. Pour une requête typique comme SELECT * FROM player WHERE game_id = 42, le préfixe SELECT * FROM player WHERE game_id = est envoyé en un seul appel au lieu de 38 appels push individuels.
L'implémentation de String::push_str en Rust copie les octets avec memcpy, ce qui est significativement plus rapide que des appels push répétés qui vérifient chacun la capacité et réallouent potentiellement.
Saut de regex pour les listes IN
La plupart des requêtes SQL ne contiennent pas de clauses IN (...). Le tokenizer suit si le mot-clé IN apparaît :
if !has_in_list
&& (b == b'I' || b == b'i')
&& i + 1 < len
&& (bytes[i + 1] == b'N' || bytes[i + 1] == b'n')
&& (i == 0 || bytes[i - 1].is_ascii_whitespace())
&& (i + 2 >= len || !bytes[i + 2].is_ascii_alphanumeric())
{
has_in_list = true;
}Si has_in_list est faux après la boucle principale, la passe regex post-traitement (IN_LIST_RE.replace_all) est entièrement sautée. Les requêtes sans clause IN ne passent jamais par la regex.
Optimisation Cow::Borrowed
Quand la regex s'exécute mais ne fait aucun remplacement (ex. IN (?) est déjà réduit), Regex::replace_all retourne Cow::Borrowed. Le code vérifie cela :
let template = if has_in_list {
match IN_LIST_RE.replace_all(&template, "IN (?)") {
Cow::Borrowed(_) => template, // pas d'allocation
Cow::Owned(s) => s, // une allocation
}
} else {
template // pas de regex du tout
};LazyLock pour la regex
La regex IN_LIST_RE est compilée une seule fois via std::sync::LazyLock (stable depuis Rust 1.80) :
static IN_LIST_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)IN\s*\(\s*\?(?:\s*,\s*\?)*\s*\)").unwrap()
});LazyLock est préféré à la macro lazy_static! car il est dans std : aucune dépendance externe nécessaire.
Autres micro-optimisations
String::with_capacity(query.len()): pré-alloue le template pour éviter la réallocation dans le cas courant où le template est légèrement plus court que l'entrée.std::mem::take(&mut current_value): déplace la valeur littérale accumulée dansparamssans cloner, remplaçantcurrent_valuepar unStringvide sur place. C'est un transfert de propriété à coût zéro.is_identifier_byte_before(): vérifie si l'octet avant un chiffre est alphanumérique ou underscore, empêchant les chiffres dans les identifiants (player2,col_1) d'être mal interprétés comme des littéraux numériques.
Normaliseur HTTP
Vérification UUID codée à la main
Le normaliseur HTTP remplace les segments de chemin UUID par {uuid}. Au lieu d'utiliser une regex, la vérification est codée à la main :
fn is_uuid(s: &str) -> bool {
if s.len() != 36 { return false; }
let b = s.as_bytes();
b[8] == b'-' && b[13] == b'-' && b[18] == b'-' && b[23] == b'-'
&& b.iter().enumerate().all(|(i, &c)| {
matches!(i, 8 | 13 | 18 | 23) || c.is_ascii_hexdigit()
})
}Pourquoi codé à la main ? Cette fonction est appelée sur chaque segment de chemin de chaque URL HTTP dans le pipeline. Une regex compilée (Regex::is_match) fait tourner le moteur regex à chaque appel. La vérification codée à la main consiste en une vérification de longueur (rejet rapide pour >99% des segments), quatre comparaisons d'octets pour les positions des tirets et une seule passe pour les chiffres hexadécimaux.
split_origin et regroupement conscient du host
fn split_origin(target: &str) -> (Option<&str>, &str) {
match target
.strip_prefix("http://")
.or_else(|| target.strip_prefix("https://"))
{
Some(rest) => match rest.find('/') {
Some(idx) => (Some(&rest[..idx]), &rest[idx..]),
None => (Some(rest), "/"),
},
None => (None, target),
}
}Cela sépare l'autorité du chemin sans inclure le crate url (~50 Ko de surcoût binaire). La fonction gère http://, https:// et les chemins nus (/api/foo, qui n'ont pas d'autorité).
Le host de l'appelé est ensuite gardé dans le template pour les appels adressés par DNS (GET user-svc/api/foo) et retiré pour les autorités en IP littérale. host_group_prefix classe l'autorité. Une IPv4 en décimal pointé ou une IPv6 littérale entre crochets est une adresse de réplique derrière un répartiteur de charge et est retirée, donc les pods d'un même service continuent de se regrouper dans un seul template. Un nom d'hôte DNS est mis en minuscules, dépouillé de son userinfo RFC 3986 et de son port, puis préfixé au chemin.
Garder le host empêche deux appels au même chemin sur des backends différents (http://ms-a/x, http://ms-b/x) de fusionner en un seul template et de lever un faux redundant_http. Comme la signature du finding hache le template (acknowledgments::compute_signature), la signature d'ack d'un finding HTTP sortant dépend du host de l'appelé.
Limite de paramètres de requête
Les paramètres de requête sont retirés du template URL et collectés dans params. La collection est plafonnée à 100 paramètres via .take(100) pour prévenir les allocations mémoire illimitées depuis des URLs aux query strings démesurées construites par un attaquant. Les paramètres de requête ne faisant pas partie du template normalisé, les paramètres au-delà de 100 ne sont pas extraits.
Pré-allocation
let mut result = String::with_capacity(path.len() + 8);Le + 8 tient compte du plus long remplacement ({uuid} = 6 caractères, remplaçant un UUID de 36 caractères). Cela évite la réallocation dans le cas courant où les remplacements raccourcissent le chemin.
Dispatcher de normalisation
La fonction normalize() redirige selon event_type :
pub fn normalize(event: SpanEvent) -> NormalizedEvent {
match event.event_type {
EventType::Sql => { /* sql::normalize_sql(...) */ }
EventType::HttpOut => { /* http::normalize_http(...) */ }
EventType::Messaging => { /* "{operation} {target}", sans params */ }
}
}Le bras messaging ne normalise rien : un nom de topic ou de file est déjà un template, sans partie variable à extraire. Le faire passer par normalize_http masquerait les segments numériques en {id} et retirerait l'identifiant de compte d'un ARN de file SQS, fusionnant deux comptes AWS dans un seul template. La seule transformation appliquée vit dans sanitize_span_event, pas ici. Une destination qui comporte un schéma (amqp://user:pass@host/q) se voit retirer ses identifiants et sa chaîne de requête, car contrairement à SQL et HTTP ce bras n'a aucun parseur entre une chaîne fournie par l'opérateur et le template du finding. Une destination sans schéma est laissée intacte, octet pour octet, puisque @ et # sont autorisés dans un nom de file (ORDERS@QM1, logs.#) et que les réécrire fusionnerait des destinations distinctes.
normalize_all() est un simple events.into_iter().map(normalize).collect(). Le into_iter() consomme le vecteur d'entrée et chaque SpanEvent est déplacé (pas cloné) dans le normaliseur.
Défense en profondeur
Troncature des requêtes. normalize_sql tronque l'entrée à MAX_QUERY_LEN (64 Ko) avant le traitement pour empêcher le tokenizer à états de tourner sur des entrées démesurées construites par un attaquant. La troncature utilise floor_char_boundary pour éviter de couper des caractères UTF-8 multi-octets. C'est une deuxième couche après les limites de champs de sanitize_span_event appliquées à la frontière d'ingestion.