Algorithmes de détection
La détection est la quatrième étape du pipeline. Elle analyse les traces corrélées pour identifier sept types d'anti-patterns : les requêtes N+1, les appels redondants, les opérations lentes, le fanout excessif, les services bavards, la saturation du pool de connexions et les appels sérialisés.
Pattern partagé : clés HashMap empruntées
Les trois détecteurs regroupent les spans par une clé composite. Un point clé est que les spans vivent dans la struct Trace, qui survit à la fonction de détection. Cela signifie que nous pouvons emprunter depuis les spans au lieu de cloner :
// N+1 : grouper par (event_type, template)
HashMap<(&EventType, &str), Vec<usize>>
// Redondant : grouper par (event_type, template, params)
HashMap<(&EventType, &str, &[String]), Vec<usize>>
// Lent : grouper par (event_type, template)
HashMap<(&EventType, &str), Vec<usize>>Les valeurs sont des Vec<usize> : des indices dans trace.spans plutôt que des spans clonés. Cela garde le HashMap petit et évite de copier les données d'événements.
Pour une trace avec 50 spans, chacun ayant un template de 40 caractères, les clés empruntées économisent 50 × 40 = 2 000 octets d'allocations de String par passe de groupement.
Détection N+1
Algorithme
- Ignorer les spans SQL dont le template est une commande de session (
normalize::sql::is_session_command) - Grouper les spans par
(&EventType, &str template) - Ignorer les groupes avec moins de
thresholdoccurrences (défaut 5) - Compter les jeux de paramètres distincts via
HashSet<&[String]> - Ignorer les groupes avec moins de
thresholdparamètres distincts (mêmes paramètres = redondant, pas N+1) - Calculer la fenêtre temporelle entre le plus ancien et le plus récent timestamp
- Ignorer les groupes où la fenêtre dépasse
window_limit_ms(défaut 500ms) - Assigner la sévérité : Critical si >= 10 occurrences, Warning sinon
Paramètres distincts via slices empruntés
let distinct_params: HashSet<&[String]> = indices
.iter()
.map(|&i| trace.spans[i].params.as_slice())
.collect();Utiliser &[String] comme clé de HashSet est un choix de conception critique :
- Pas d'allocation : emprunte le Vec existant comme référence de slice
- Pas de bug de collision : compare directement le contenu complet du Vec, contrairement à une approche
join(",")où["a,b"]et["a", "b"]produiraient la même chaîne jointe
La bibliothèque standard de Rust implémente Hash et Eq pour &[T] quand T: Hash + Eq, rendant cela à coût zéro.
Calcul de fenêtre basé sur les itérateurs
pub fn compute_window_and_bounds_iter<'a>(
mut iter: impl Iterator<Item = &'a str>,
) -> (u64, &'a str, &'a str) {
let Some(first) = iter.next() else {
return (0, "", "");
};
let mut min_ts = first;
let mut max_ts = first;
let mut has_second = false;
for ts in iter {
has_second = true;
if ts < min_ts { min_ts = ts; }
if ts > max_ts { max_ts = ts; }
}
// ...
}Pourquoi un itérateur au lieu de &[&str] ? L'appelant devrait d'abord collecter les timestamps dans un Vec :
// Ancien (alloue) :
let timestamps: Vec<&str> = indices.iter().map(|&i| ...).collect();
let (w, min, max) = compute_window_and_bounds(×tamps);
// Nouveau (zéro allocation) :
let (w, min, max) = compute_window_and_bounds_iter(
indices.iter().map(|&i| trace.spans[i].event.timestamp.as_str())
);La version basée sur les itérateurs élimine une allocation Vec<&str> par groupe de détection. Avec 3 détecteurs × plusieurs groupes par trace × milliers de traces, cela s'accumule.
Le booléen has_second remplace une variable count qui n'était utilisée que pour vérifier count < 2. Cela évite d'incrémenter un compteur à chaque itération.
Parseur de timestamp ISO 8601
fn parse_timestamp_ms(ts: &str) -> Option<u64> {
let time_part = ts.split('T').nth(1)?;
let time_part = time_part.trim_end_matches('Z');
let mut colon_parts = time_part.split(':');
let hours: u64 = colon_parts.next()?.parse().ok()?;
let minutes: u64 = colon_parts.next()?.parse().ok()?;
let sec_str = colon_parts.next()?;
// ... parser les secondes et la partie fractionnaire
}Pourquoi pas chrono ? chrono ajoute ~150 Ko au binaire et parse ~200ns par timestamp. Ce parseur artisanal gère le format fixe (YYYY-MM-DDTHH:MM:SS.mmmZ) en ~5ns en découpant sur des délimiteurs connus et en utilisant des appels itérateurs .next() au lieu de collecter dans des Vecs.
Le parseur utilise des itérateurs partout (split(':') -> .next(), split('.') -> .next()) pour éviter d'allouer des collections Vec<&str> intermédiaires.
Le parseur calcule les millisecondes depuis l'epoch Unix en parsant les composantes date (YYYY-MM-DD) et heure. La conversion date-vers-jours utilise l'algorithme de Howard Hinnant (domaine public), sans dépendance externe.
Comparaison lexicographique des timestamps
Les timestamps min/max sont trouvés via comparaison de chaînes : if ts < min_ts { min_ts = ts; }. Cela fonctionne car les timestamps ISO 8601 avec des champs de largeur fixe (2025-07-10T14:32:01.123Z) se trient chronologiquement lorsqu'ils sont comparés lexicographiquement. C'est garanti par le standard ISO 8601, Section 5.3.3.
Classification sanitizer-aware
Les agents OpenTelemetry et les drivers de base de données collapsent les littéraux SQL en tokens de placeholder avant que l'instruction n'atteigne perf-sentinel. Le style de placeholder dépend de la stack : les agents JDBC produisent ?, les drivers PostgreSQL natifs (pgx, asyncpg, sqlx, node-pg) émettent $1/$2 (que normalize_sql réécrit en $? avec des params vides depuis v0.7.7), les drivers Python DB-API émettent %s, les drivers .NET émettent @p0/@Name, et Oracle/SQLAlchemy émettent :name. Dans tous les cas, l'instruction sanitizée arrive dans perf-sentinel avec le placeholder déjà en place et un vecteur params vide. Le check standard distinct_params >= threshold voit un seul slice de params vides et ne se déclenche jamais, le détecteur redundant regroupe alors tous les spans et les classe à tort en redundant_sql.
L'heuristique dans crates/sentinel-core/src/detect/sanitizer_aware.rs rétablit la classification correcte via quatre signaux, évalués dans l'ordre :
looks_sanitized: chaque span a un placeholder reconnu dans son template (?,$?,%s,@alpha,:alpha) et un vecteurparamsvide. Voirtemplate_has_placeholderdanssanitizer_aware.rspour la liste complète. Requis pour activer l'heuristique.has_orm_scope: au moins un OpenTelemetry instrumentation scope sur les spans correspond à un marqueur ORM connu (Hibernate, Spring Data, EF Core, SQLAlchemy, ActiveRecord, GORM, Prisma, Diesel, Laravel/Eloquent, Doctrine, etc.). Les marqueurs sont matchés avec un check de word-boundary (précédé et suivi d'un byte non-alphanumérique), doncjpane se déclenche que surspring-data-jpaet apparentés, jamais surmyappjpastats. Une correspondance positive est traitée comme une preuve forte de N+1.timing_variance_suggests_n_plus_one: quand le signal scope est absent, fallback sur le coefficient de variation deduration_us. Un vrai N+1 frappe différentes lignes avec différents états de cache, donc l'écart est plus large, des appels redondants en cache se regroupent serré. Seuil0.5empirique.sequential_siblings_indexed(mode Strict uniquement) : tous les spans partagent un mêmeparent_span_idnon vide et le groupe chaîneprev.end_us <= next.start_usaprès tri par timestamp de début. Les bornes sont calculées en microsecondes pour éviter la troncation silencieuse des durées sous-milliseconde. Substituehas_orm_scopesur les piles bare-driver (Vert.x reactive PG, pgx, asyncpg, sqlx, PrismaqueryRaw) qui n'émettent jamais de scope ORM.high_occurrence(mode Strict, toutes branches) : un nombre d'occurrences élevé (>= 3 xn_plus_one_threshold, par défaut 15) sert de signal primaire ET corroboratif. Sous la gardelooks_sanitized(params vides, template avec?), 15+ templates sanitisés identiques dans un seul trace est structurellement un n+1 quel que soit le scope ORM, les siblings séquentiels ou la variance. Les boucles de polling legacy sous le seuil (typiquement 5-10 appels par requête) restent classées enredundant_sql.
Les quatre modes d'émission (Auto, Strict, Always, Never) sont documentés dans Configuration § "sanitizer_aware_classification" avec leurs trade-offs précision/rappel.
Le détail HTML rend cette décision vérifiable sans la modifier : les N+1 directs portent le libellé direct, les groupes récupérés le libellé heuristic, et la vue affiche la fenêtre d'observation, le nombre de paramètres distincts, les statistiques temporelles p50/p99/CV disponibles, ainsi qu'une ligne horodatée avec durée/statut pour chaque span incriminé exact de la trace représentative. Pour un finding inter-traces, le résumé conserve le nombre total d'occurrences et la vue précise combien d'entre elles sont prouvées par la trace représentative. Les paramètres et cibles bruts restent masqués, et les spans sans rapport restent regroupés. Les anciens rapports dépourvus de configuration de détection ne permettent pas de reconstruire les identifiants exacts : les spans correspondants restent alors regroupés au lieu d'être présentés comme des preuves individuelles.
Limite connue
looks_sanitized ne peut pas distinguer un ? littéral sanitizé d'un opérateur d'existence JSONB PostgreSQL (data ? 'key') quand ce dernier apparaît dans une requête sans autre littéral. La direction du préjudice est asymétrique : un groupe JSONB mal classé bascule de redundant_sql vers n_plus_one_sql, les deux contribuant à parts égales aux avoidable_io_ops GreenOps, seul le texte de la suggestion diffère.
Extension HTTP (0.7.8+)
Le même aiguillage couvre aussi les groupes HTTP sortants via classify_http_group_indexed. HTTP n'a pas d'analogue de looks_sanitized (le normaliseur collapse toujours les IDs de path en {id}/{uuid}, les params ne sont jamais effacés comme un sanitizer SQL les efface) ni de notion de scope ORM. Le chemin HTTP s'appuie donc sur un jeu de signaux plus étroit :
Auto/Always: la variance de timing seule (CV>= 0.5).Strict: un signal primaire (placeholder HTTP dans le template, occurrence élevée, ou siblings séquentiels) corroboré par la variance de timing. Contrairement au chemin SQL, l'occurrence élevée seule n'est pas une corroboration suffisante pour HTTP, car sans le filtrelooks_sanitizedune boucle de polling active ou un appel répété servi par un CDN serait promu enn_plus_one_http.
Limite connue : redaction de la query string
La détection des N+1 HTTP exige que le paramètre variable soit visible dans le span. Une boucle N+1 qui fait varier un segment de path est détectée (params extraits distincts, ou le placeholder {id} ancre le primaire Strict). Une boucle N+1 qui fait varier un paramètre de query est invisible quand l'instrumentation redacte la query string avant l'export. OpenTelemetry .NET System.Net.Http la redacte en ?* par défaut, donc chaque appel porte un url.full identique au byte près, distinct_params retombe à 1, et le groupe est correctement classé en redundant_http. Le paramètre distinctif a été détruit en amont, donc aucun consommateur de traces ne peut le récupérer. Voir Limites § "Redaction de la query string HTTP et visibilité des N+1" pour les contournements côté opérateur.
Détection redondante
Clés de slice empruntées
HashMap<(&EventType, &str, &[String]), Vec<usize>>La clé en trois parties inclut le slice complet des paramètres, garantissant que deux spans avec le même template mais des paramètres différents sont dans des groupes différents. C'est le comportement correct : la détection redondante signale les doublons exacts (même template ET mêmes paramètres).
L'utilisation de &[String] au lieu de joindre les paramètres en une seule chaîne prévient un bug subtil de collision : ["a,b"] (un paramètre contenant une virgule) et ["a", "b"] (deux paramètres) produiraient la même clé jointe "a,b" mais sont des jeux de paramètres sémantiquement différents.
Commandes de session
Comme pour la détection N+1, le regroupement ignore les spans SQL dont le template est une commande de session. Un driver poolé en émet une par acquisition de connexion : une requête qui en emprunte N remonterait N doublons exacts qu'aucun cache ne peut dédupliquer. Ces statements restent comptés dans total_io_ops mais sortent de avoidable_io_ops, qui est dérivé des findings : io_waste_ratio baisse donc, et un seuil io_waste_ratio_max calibré sur une version antérieure devient plus permissif.
Sévérité
- Info (< 5 occurrences) : courant pour les consultations de config, les health checks
- Warning (>= 5 occurrences) : probablement un bug de boucle ou un cache manquant
Le seuil de 2 (minimum pour signaler) attrape tout doublon exact. Contrairement au N+1 qui nécessite 5+ occurrences, même 2 requêtes identiques dans une seule requête sont suspectes et méritent d'être signalées au niveau Info.
Paramètres bindés des ORM
Les ORM qui utilisent des paramètres nommés (Entity Framework Core avec @__param_0, Hibernate avec ?1) produisent des spans SQL ou les valeurs réelles ne sont pas visibles dans db.statement/db.query.text. Dans ce cas, les patterns N+1 (même requête avec des valeurs différentes) apparaissent comme des requêtes redondantes (même template, mêmes params visibles), car perf-sentinel ne peut pas distinguer les valeurs bindées. Les deux findings identifient correctement le pattern de requêtes répétées. Les ORM qui injectent les valeurs littérales (SeaORM en requêtes brutes, JDBC sans prepared statements) permettent une classification précise N+1 vs redondant.
Classification consciente du sanitizer (0.5.7+)
La même forme apparaît dès que l'agent OpenTelemetry exécute son sanitizer d'instructions SQL (actif par défaut), puisque les littéraux sont remplacés par ? avant que le span n'atteigne perf-sentinel. La règle standard de paramètres distincts ne voit qu'un seul groupe de paramètres vides et rejette le groupe, donc le détecteur de redondance classe à tort le N+1 en redundant_sql et l'opérateur reçoit la mauvaise recommandation.
L'heuristique consciente du sanitizer introduite en 0.5.7 restaure la classification correcte en effectuant une seconde passe sur les mêmes groupes (event_type, template) que la première passe a rejetés. Elle ne s'active que lorsque chaque span du groupe a un vecteur params vide et un placeholder reconnu dans son template (la signature sur le fil d'un N+1 sanitisé). Depuis v0.7.7 le check template_has_placeholder reconnaît cinq styles : ? (JDBC), $? (PostgreSQL natif, normalisé depuis $1/$2), %s (Python DB-API), @alpha (.NET, excluant @@ variables système), :alpha (Oracle/SQLAlchemy, excluant :: casts). Les requêtes vraiment sans littéraux comme SELECT NOW() n'ont aucun placeholder et n'activent pas l'heuristique. Elle évalue ensuite deux signaux indépendants :
- Marqueur de scope d'instrumentation (confiance élevée). Les chaînes
instrumentation_scopespar span sont fouillées, en mode insensible à la casse, à la recherche de l'une des sous-chaînes ORM connues :spring-data,hibernate,jpa,micronaut-data,jdbi,r2dbc,entityframeworkcore,entity-framework,sqlalchemy,django,active-record/activerecord,gorm,sequelize,prisma,typeorm,mongoose,sea-orm,diesel. Les drivers SQL bare commesqlx(Go/Rust),pgx,asyncpget le client réactif Vert.x PG sont intentionnellement exclus : leurs patterns n+1 sont pris en charge par le signal "siblings séquentiels". Une correspondance fait basculer le verdict enLikelyNPlusOne. - Repli sur la variance temporelle (confiance moyenne). En l'absence de marqueur ORM, l'heuristique calcule le coefficient de variation (
écart-type / moyenne) desduration_us. Les vrais accès N+1 touchent des lignes différentes avec des états de cache différents, donc les durées s'étalent (CV typiquement 0,4 à 1,0), les appels redondants sur du contenu en cache se regroupent (CV proche de 0). Le seuil de0,5est empirique et constitue le seul levier de l'heuristique. Au moins 3 spans sont nécessaires pour une estimation de variance stable.
Le mode configurable [detection] sanitizer_aware_classification positionne l'émission sur un cadran rappel-vs-précision en quatre crans : auto (défaut) émet dès qu'un des signaux se déclenche, strict (0.5.8+) exige un signal primaire (scope ORM OU siblings séquentiels) plus un signal corroboratif (variance OU, sur la branche ORM, nombre d'occurrences élevé), always reclassifie tout groupe sanitisé sans condition, et never désactive entièrement la seconde passe. Les findings émis par l'heuristique portent classification_method = SanitizerHeuristic pour permettre aux consommateurs de les distinguer des classifications directes. Le mode choisit où se placer sur le compromis :
autoprivilégie le rappel : capture tous les N+1 induits par un ORM parce que le scope ORM seul déclenche le verdict, au prix d'absorber des findingsredundant_sqllégitimes sur les stacks Spring Data / EF Core (unfindById(sameId)appelé en boucle et servi depuis le row cache bascule enn_plus_one_sql).strictprivilégie la précision : préserve les findingsredundant_sqlsur les requêtes identiques de compte modéré (sous la barre3 x threshold). Au-dessus de la barre (par défaut 15 occurrences), tout groupe sanitisé se déclenche quel que soit le scope ORM, les siblings séquentiels ou la variance. Recommandé quand des findingsredundant_sqlexploitables ont de la valeur dans votre environnement.
Limites connues : une vraie redondance à un seul paramètre dont le littéral se trouve écrasé par le sanitizer (par exemple SELECT * FROM config WHERE key = ? interrogé 10 fois pour la même clé) ne peut pas être distinguée d'un N+1 sans signal de scope ou de variance. En mode auto elle bascule en n_plus_one_sql dès qu'un scope ORM est présent (sens de réduction du dommage, le batch fetch est un sur-ensemble strict de "mettre une valeur en cache"). En mode strict elle reste redundant_sql parce que la variance temporelle est basse. En mode always elle bascule toujours. En mode never l'heuristique est court-circuitée.
Le signal de variance temporelle (timing_variance_suggests_n_plus_one, coefficient de variation > 0,5) porte un réglage à dommage asymétrique : un faux positif échange simplement redundant_sql contre n_plus_one_sql (même poids dans avoidable_io_ops, seul le texte de suggestion diffère), tandis qu'un faux négatif laisse un vrai N+1 silencieux, le seuil favorise donc les faux positifs. Sous strict, le signal devient porteur comme seul corroborateur sur la branche ORM en dessous de la barre de haute occurrence, et il a un angle mort en cache chaud : un vrai N+1 induit par un ORM contre un cache de lignes entièrement chaud (par exemple 100 lectures par clé primaire avec toutes les lignes dans shared_buffers) peut se resserrer à environ 10 % (CV autour de 0,1) et rester silencieux. Le seuil est [detection] sanitizer_aware_min_cv, 0,5 par défaut pour tous les modes. Le laboratoire de simulation a fourni le cas empirique que le défaut attendait : sous strict, dix lookups Doctrine identiques servis depuis le cache sur un worker PHP-FPM ont mesuré un CV proche de 0,75 une fois le runner chargé, franchissant la barre et transformant un finding redundant_sql en n_plus_one_sql. Relever le réglage à 1,0 y restaure le verdict redondant, tandis que la barre de haute occurrence garde les vrais N+1 signalés.
Détection lente
Arithmétique saturante
let threshold_us = threshold_ms.saturating_mul(1000);
// ...
if max_duration_us > threshold_us.saturating_mul(5) {
Severity::Critical
}saturating_mul retourne u64::MAX en cas de dépassement au lieu de revenir à zéro. Cela empêche un threshold_ms = u64::MAX malveillant ou mal configuré de désactiver les seuils de sévérité.
Ne fait pas partie du ratio de gaspillage
Les findings lents ont green_impact.estimated_extra_io_ops = 0. Ce sont des opérations nécessaires qui se trouvent être lentes : elles ont besoin d'optimisation (indexation, cache), pas d'élimination. Les inclure dans le ratio de gaspillage confondrait "I/O évitables" (N+1, redondant) avec "I/O lentes" (qui nécessitent une solution différente).
Orchestration de la détection
pub fn detect(traces: &[Trace], config: &DetectConfig) -> Vec<Finding> {
let mut findings = Vec::new();
for trace in traces {
findings.extend(detect_n_plus_one(trace, ...));
findings.extend(detect_redundant(trace));
findings.extend(detect_slow(trace, ...));
}
findings
}Les quatre détecteurs s'exécutent séquentiellement sur chaque trace. Bien qu'ils pourraient théoriquement partager une seule passe de groupement, les types de clés diffèrent ((&EventType, &str) vs (&EventType, &str, &[String])) et les implémentations séparées sont plus claires et testables indépendamment. Avec des tailles de trace typiques de 10-50 spans, quatre passes O(n) sont négligeables.
Détection de fanout
Algorithme
- Regrouper les spans par
parent_span_id - Ignorer les groupes où le parent a
max_fanoutou moins d'enfants (défaut 20) - Pour chaque parent dépassant le seuil, émettre un finding
ExcessiveFanout - Sévérité : Warning si >
max_fanout, Critical si > 3xmax_fanout
Pas dans le ratio de gaspillage
Comme les findings lents, les findings de fanout ont green_impact.estimated_extra_io_ops = 0. Le fanout excessif est un problème structurel qui nécessite une optimisation architecturale, pas une élimination d'I/O.
Détection des services bavards
Algorithme
- Pour chaque trace, compter les spans de type
http_out - Ignorer les traces avec moins de
chatty_service_min_callsappels HTTP sortants (défaut 15) - Émettre un finding
chatty_serviceavec le service et le nombre total d'appels - Sévérité : Warning si > seuil, Critical si > 3x seuil
Pas dans le ratio de gaspillage
Les findings de services bavards ont green_impact.estimated_extra_io_ops = 0. Un service bavard est un problème architectural (granularité de décomposition des services) qui nécessite un redesign des API, pas une simple élimination d'I/O. Le compteur de gaspillage ne devrait refléter que les I/O qui peuvent être supprimées par refactoring local (batching, cache).
Différence avec le fanout
Le fanout excessif détecte un parent unique avec trop d'enfants directs. Le service bavard détecte une trace entière avec trop d'appels HTTP sortants, indépendamment de la structure parent-enfant. Une trace peut déclencher les deux si un seul parent génère tous les appels ou seulement le service bavard si les appels sont répartis sur plusieurs parents.
Détection de saturation du pool de connexions
Algorithme
- Regrouper les spans SQL par service
- Pour chaque service, trier les spans par timestamp de début
- Exécuter un algorithme de balayage (sweep line) : traiter chaque span comme un intervalle
[début, début + durée], suivre la concurrence maximale - Ignorer les services où la concurrence maximale est inférieure à
pool_saturation_concurrent_threshold(défaut 10) - Émettre un finding
pool_saturationavec le service et le pic de concurrence - Sévérité : toujours Warning, quel que soit le pic. À la différence du fanout et du chatty service, ce détecteur n'a pas de palier Critical
Sweep line
L'algorithme de balayage crée deux événements par span : un événement d'ouverture au timestamp de début et un événement de fermeture au timestamp de fin (début + durée). Les événements sont triés chronologiquement. Un compteur est incrémenté à chaque ouverture et décrémenté à chaque fermeture. La valeur maximale atteinte par le compteur est la concurrence pic.
Pas dans le ratio de gaspillage
Les findings de saturation du pool ont green_impact.estimated_extra_io_ops = 0. Elles signalent un risque de contention des ressources, pas des I/O évitables.
Détection des appels sérialisés
Algorithme
- Écarter les spans SQL frères dont le template est une commande de session : un
COMMITne peut pas sortir de la chaîne, et le compter gonfle à la fois le nombre de maillons et le total séquentiel - Grouper les spans frères par
parent_span_id - Pour chaque groupe, trier les enfants par temps de fin (croissant)
- Trouver la plus longue sous-séquence non chevauchante via programmation dynamique (Weighted Interval Scheduling avec poids unitaires)
- Si la séquence optimale a >=
serialized_min_sequential(défaut 3) spans avec des templates distincts, émettre un finding - Sévérité : toujours Info (heuristique, risque inhérent de faux positifs)
Entrée : trace avec N spans, groupés par parent_span_id
Sortie : 0 ou plusieurs findings SerializedCalls
pour chaque parent_id dans spans_par_parent :
enfants = spans avec ce parent_id
si len(enfants) < serialized_min_sequential :
passer
trier enfants par end_time croissant
// Calcul des prédécesseurs : pour chaque span i, recherche binaire
// de p(i), le span j (j < i) le plus à droite dont end_time <= start_time de i.
// O(log n) par span.
// Récurrence DP :
// dp[i] = max(dp[i-1], dp[p(i)] + 1)
// où dp[i] = plus longue sous-séquence non chevauchante dans enfants[0..=i]
// Backtrack depuis dp[n-1] pour reconstruire les spans sélectionnés.
// Garde : le prédécesseur doit être strictement inférieur à l'index courant
// pour garantir la terminaison sur des entrées dégénérées (spans de durée zéro).
si len(sélectionnés) >= serialized_min_sequential
ET templates_distincts(sélectionnés) > 1 :
émettre finding pour la séquence sélectionnéeComplexité : O(n log n) pour le tri + O(n log n) pour toutes les recherches binaires + O(n) pour le remplissage DP et le backtrack = O(n log n) total par groupe parent. C'est le même coût asymptotique que l'approche gloutonne plus simple, mais la programmation dynamique garantit de trouver la plus longue séquence non chevauchante possible. Par exemple, avec les spans A:[0-200ms], B:[100-150ms], C:[160-300ms], D:[310-400ms], une approche gloutonne triée par temps de début sélectionnerait {A, D} (longueur 2), tandis que la DP identifie correctement {B, C, D} (longueur 3).
La recherche binaire utilise partition_point directement sur le slice trié, évitant une allocation séparée pour le tableau des prédécesseurs.
Pourquoi info uniquement
Le détecteur ne peut pas observer les dépendances de données entre les appels. Deux appels séquentiels à des services différents peuvent être intentionnellement ordonnés (par exemple, créer un enregistrement puis notifier un service dépendant). La sévérité info signale une opportunité d'investigation, pas un défaut confirmé.
Filtrage de template
Le détecteur ignore les séquences où tous les spans partagent le même template normalisé. Ce motif est un N+1 (même opération répétée avec des paramètres différents), pas une sérialisation. En exigeant des templates différents, le détecteur cible le pattern "récupérer l'utilisateur, puis ses commandes, puis ses préférences" où les appels sont indépendants et pourraient s'exécuter en parallèle.
Estimation du gain de temps
Le finding inclut le gain de temps potentiel : durée_séquentielle_totale - durée_individuelle_max. Si 3 appels séquentiels prennent chacun 100 ms, les paralléliser pourrait réduire la latence de 300 ms à 100 ms, soit 200 ms économisées. C'est une estimation optimale qui suppose qu'il n'y a pas de contention sur des ressources partagées.
Pas dans le ratio de gaspillage
Les findings d'appels sérialisés ont green_impact.estimated_extra_io_ops = 0. Paralléliser des appels séquentiels réduit la latence mais ne réduit pas le nombre total d'opérations I/O. Le ratio de gaspillage ne mesure que les I/O éliminables.
Percentiles lents cross-trace
detect_slow_cross_trace collecte les spans lents à travers toutes les traces d'un batch (toute l'entrée pour analyze, un batch d'éviction dans le daemon) et calcule les percentiles p50/p95/p99 par template normalisé. Seuls les templates apparaissant dans au moins 2 traces distinctes sont rapportés. Le daemon compte aussi les spans lents d'un batch à l'autre, voir la section suivante.
Fenêtre lente inter-batchs (daemon)
Le daemon analyse des batchs d'éviction d'environ trace_ttl_ms / 2, donc un template lent une fois toutes les quelques minutes ne réunit jamais slow_query_min_occurrences spans dans un même batch. daemon/slow_window.rs tient, sur le worker d'analyse, une fenêtre d'épisodes lents par clé (type d'événement, service, grouping, template normalisé). [detection] slow_query_window_minutes (15 par défaut, 0 désactive, plage 0-60) fixe la fenêtre. analyze et les autres commandes batch ne la construisent jamais.
- Épisodes. Les spans lents d'une clé situés à moins de max(60 s, 1.5 x
trace_ttl_ms) du premier span d'un épisode comptent pour un seul épisode, qui garde le span le plus lent. Un span lent isolé, ou un verrou bref dont les victimes sont évincées dans ce laps de temps, reste un seul épisode et n'alimente que l'histogramme des durées. - Suppression. Un span lent dont le triplet (type, template, grouping) a déjà produit un finding lent dans le même batch n'est pas compté. L'entrée de sa clé perd ses épisodes et entre en période de silence, comme si elle avait été rapportée.
- Émission. Une clé est rapportée quand un batch ouvre un nouvel épisode et que la fenêtre contient au moins
slow_query_min_occurrencesépisodes issus d'au moins 2 traces distinctes. Le temps est celui de l'analyse, pas l'horodatage des spans. - Période de silence. Après un rapport, la clé vide ses épisodes et reste muette pendant une fenêtre. Un problème persistant est de nouveau rapporté à son premier nouvel épisode après cette période, dès que la fenêtre contient assez d'épisodes.
- Forme. Le finding est construit par la même fonction qu'un finding lent cross-trace de batch : même type, même règle de sévérité, même libellé de suggestion et même signature, il se replie donc avec eux dans le findings store.
pattern.occurrenceset les percentiles comptent des épisodes, un span (le plus lent) par épisode. - Plafond de clés. Au plus 1024 clés sont suivies. Les spans lents d'une nouvelle clé au-delà du plafond sont refusés et comptés dans
perf_sentinel_slow_window_keys_refused_total, avec un avertissement journalisé une seule fois par processus. - Trace représentative. Le
trace_iddu finding est la trace du span le plus lent de l'épisode qui vient de s'ouvrir, qui appartient au batch courant et que le traces store conserve pour/api/explain. La signature n'en dépend pas. Les autres épisodes viennent de batchs antérieurs, dont le store peut ne plus avoir les traces. - Verrous longs. Une lenteur qui dure plus de deux épisodes, par exemple un verrou de ligne tenu plusieurs minutes sur un template peu sollicité, est quand même rapportée : les durées seules ne distinguent pas un verrou d'un problème chronique. Mettre la fenêtre à
0désactive la fonctionnalité.
Orchestration de la détection (mise à jour)
pub fn detect(traces: &[Trace], config: &DetectConfig) -> Vec<Finding> {
let mut findings = Vec::new();
for trace in traces {
findings.append(&mut detect_n_plus_one(trace, ...));
findings.append(&mut detect_redundant(trace));
findings.append(&mut detect_slow(trace, ...));
findings.append(&mut detect_fanout(trace, config.max_fanout));
findings.append(&mut detect_chatty(trace, config.chatty_service_min_calls));
findings.append(&mut detect_pool_saturation(trace, config.pool_saturation_concurrent_threshold));
findings.append(&mut detect_serialized(trace, config.serialized_min_sequential));
}
findings
}Les sept détecteurs s'exécutent séquentiellement sur chaque trace. append(&mut ...) est utilisé à la place de extend() pour transférer les buffers en O(1) sans passer par un itérateur. L'analyse des percentiles lents cross-trace s'exécute séparément dans pipeline.rs après la détection par trace et avant le scoring.
Corrélation temporelle cross-trace (mode daemon)
En mode watch, perf-sentinel observe l'ensemble des findings sur tous les traces au fil du temps. Le module detect/correlate_cross.rs fournit un moteur de corrélation qui identifie les co-occurrences récurrentes entre findings de services différents : par exemple, "chaque fois que le N+1 dans order-svc se déclenche, une saturation du pool apparaît dans payment-svc dans les 2 secondes."
Deux horloges
Chaque finding porte deux instants. Son temps d'événement est first_timestamp, le début de son premier span fautif, lu par time::parse_iso8601_utc_to_ms ; une valeur absente ou non UTC retombe sur le temps d'ingestion. Son temps d'ingestion est le now_ms du tick d'analyse qui l'a produit. L'appariement, l'orientation et le délai utilisent le temps d'événement : deux findings s'apparient quand leurs propres spans ont démarré à moins de lag_threshold_ms l'un de l'autre, quels que soient les ticks qui les ont analysés. La rétention, l'éviction et la fenêtre de rapport utilisent le temps d'ingestion, donc un trafic rejoué ou décalé vieillit quand même.
Structure du corrélateur
CrossTraceCorrelator est une struct possédée par la boucle événementielle du daemon :
pub struct CrossTraceCorrelator {
occurrences: VecDeque<FindingOccurrence>,
pair_counts: HashMap<PairKey, PairState>,
endpoints: HashMap<Arc<CorrelationEndpoint>, HalfWindowCount>,
now_idx: u64,
pruned_idx: u64,
config: CorrelationConfig,
}occurrences: l'horizon d'appariement, unVecDequedans l'ordre d'ingestion. Chaque entrée porte l'endpoint interné,event_ms,ingest_ms, l'indice de grille à l'ingestion, un trace id plafonné etcounted_targets, les cibles pour lesquelles cette occurrence a déjà compté comme source. Une entrée sort dès queingest_ms + lag_threshold_ms + ingest_skew_ms < now_ms. L'horizon ne dépend que du délai et du décalage, jamais dewindow_ms: une fenêtre de 24 h garde le même deque qu'une fenêtre de 10 min. Le décalage vaut2 x trace_ttl_ms, donc le deque et le parcours que chaque finding en fait croissent avec le TTL (environ une minute de findings avec les 30 s par défaut).endpoints: le registre des endpoints. ChaqueCorrelationEndpointdistinct (type de finding, service, template, regroupement) est stocké une seule fois derrière unArc, et le deque, les clés de paire etcounted_targetspartagent cette allocation : un long template SQL n'est gardé qu'une fois, quel que soit le nombre de findings qui le portent. La valeur est le compteur d'occurrences de l'endpoint, dénominateur de la confiance.pair_counts: indexé parPairKey(source, cible), deuxArcinternés. ChaquePairStatecontient le compteur de co-occurrences, un reservoir borné de délais, un compteurtotal_observations, un état PRNGSplitMix64,first_seen_ms/last_seen_mssur l'horloge d'ingestion et les trace ids côté source et côté cible de la dernière correspondance.
Grille globale en demi-fenêtres
Les deux compteurs, co-occurrences de la paire et occurrences de l'endpoint, sont un HalfWindowCount { idx, cur, prev } sur une grille unique partagée par tout le corrélateur : idx = now_ms / (window_ms / 2). Un compteur vaut prev + cur dans son propre seau, cur un pas plus tard et 0 au-delà : il couvre entre une demi-fenêtre et une fenêtre, jamais plus. Numérateur et dénominateur reposent sur les mêmes seaux et couvrent la même période : une co-occurrence est créditée au seau où son occurrence source a été comptée, pas au seau de l'ingestion la plus tardive. Un crédit un seau derrière celui du compteur va dans prev, un crédit plus ancien est abandonné. Les lectures sont paresseuses : aucune rotation des compteurs à chaque tick, et un compteur que plus rien ne touche vaut 0 une fenêtre après son dernier incrément. now_idx ne décroît jamais, donc une horloge murale qui recule ne remet pas les compteurs à zéro.
Décalage d'ingestion
ingest_skew_ms est la portée supplémentaire, en temps d'ingestion, qui permet à des findings analysés dans des ticks différents de se retrouver dans l'horizon. Ce n'est pas une clé TOML : setup_correlator la dérive en 2 x trace_ttl_ms. Une trace vidée sous la pression du LRU arrive tout de suite à l'analyse, alors qu'une trace vidée par le TTL attend le TTL plus au plus un tick d'éviction (un demi-TTL) ; le reste du budget couvre le batching de l'exporteur et du collecteur. La valeur par défaut de la struct (60 s) correspond au TTL par défaut de 30 s.
Algorithme d'ingestion
La méthode ingest() est appelée par process_traces une fois les findings produits et leur confiance posée, avec le lot et le now_ms du tick :
- Avancer la grille.
now_idx = max(now_idx, now_ms / demi_fenetre). - Évincer l'horizon. Retirer les occurrences en tête tant qu'elles dépassent
lag_threshold_ms + ingest_skew_msen temps d'ingestion. L'éviction ne touche aucun compteur. - Nettoyer les paires obsolètes. Une passe
HashMap::retainretire les paires dontlast_seen_msest plus ancien quewindow_ms. - Nettoyer le registre. Une fois par pas de grille, retirer les endpoints dont le compteur vaut 0 et qu'aucune paire ni occurrence de l'horizon ne retient plus (
Arc::strong_count == 1). - Apparier chaque finding. Interner son endpoint et le compter sur la grille, puis parcourir tout l'horizon. Une occurrence s'apparie quand son temps d'événement est à moins de
lag_threshold_ms, qu'il s'agit d'un autre endpoint d'un autre service, et que les deux partagent la même clé et la même valeur de regroupement. L'événement le plus ancien est la source et le plus récent la cible ; à temps d'événement égal, l'arrivée la plus ancienne reste la source. Le délai est l'écart en temps d'événement. Le finding est ensuite ajouté au deque, donc les findings d'un même lot s'apparient aussi entre eux. - Compter une fois par occurrence source. Chaque correspondance rafraîchit
last_seen_mset le trace id d'exemple (celui de la cible). Le compteur de co-occurrences, crédité à l'indice de grille de l'occurrence source, et le reservoir de délais ne bougent que si l'occurrence source n'a pas encore compté pour cet endpoint cible, ce que suit lecounted_targetsde la source. Une occurrence source suivie de trois cibles compte une fois, et le résultat ne dépend pas de l'ordre d'arrivée des quatre findings. - Appliquer le plafond de paires. Une nouvelle paire est refusée tant que la map est à
max_tracked_pairs(défaut 10 000). Quand un lot a subi des refus, la map est ramenée à 90 % du plafond en une passe : les paires sont classées par(compteur de co-occurrences fenêtré, last_seen_ms)croissant, donc les compteurs les plus bas partent d'abord et, à compteur égal, les plus anciennes. Le seuil vient deselect_nth_unstablesur les tuples de rang, donc seules les clés retirées sont clonées.
La valeur de retour est le nombre de paires perdues au plafond dans ce lot (refus plus évictions), qui alimente perf_sentinel_correlator_pairs_evicted_total.
Score de confiance
Pour chaque paire, avec tous les compteurs lus à now_idx :
co_occurrence_countest le compteur fenêtré de la paire. Les paires sousmin_co_occurrences(défaut 5) sont écartées.source_total_occurrencesest le compteur fenêtré de l'endpoint source. Une source absente du registre ou à 0 n'offre rien pour mesurer la paire, et la paire est écartée.confidence = co_occurrence_count / source_total_occurrences. Chaque co-occurrence est dans le seau de son occurrence source et compte une fois par occurrence source, donc le ratio reste dans[0, 1]; le plafond à 1 n'est qu'une précaution. Les paires sousmin_confidence(défaut 0.7) sont écartées.
median_lag_ms est la médiane du reservoir, un délai en temps d'événement. first_seen et last_seen sont sur l'horloge d'ingestion.
Reservoir sampling pour les délais
Une paire chaude qui se déclenche des milliers de fois dans la fenêtre ferait sinon croître lags_ms sans borne. Pour garder la mémoire par paire constante, record_lag utilise l'algorithme R de reservoir sampling plafonné à MAX_LAG_SAMPLES = 64 (512 octets par paire) :
- Tant que le reservoir a de la place, append inconditionnel.
- Une fois plein, tirer
runiformément dans[0, total_observations)viaSplitMix64. Sir < MAX_LAG_SAMPLES, remplacerlags_ms[r]. Conditionnellement àr < k,rest lui-même uniforme dans[0, k), donc le choix du slot est non biaisé sans tirage PRNG supplémentaire.
Le PRNG est un état SplitMix64 par PairState, seedé à la construction depuis now_ms ^ (hash_endpoint(source) << 17) ^ hash_endpoint(target). hash_endpoint est un FNV-1a déterministe sur les champs finding_type, service et template de l'endpoint (PAS le DefaultHasher qui utilise un RandomState par process et rendrait le corrélateur non déterministe entre runs). Deux runs du daemon rejouant le même fichier de traces produisent des samples reservoir identiques et donc des médianes identiques.
Calcul de la médiane
Le helper median() trie un clone des valeurs de délai et retourne l'élément médian (longueur impaire) ou la moyenne des deux médians (longueur paire). Le tri est borné par MAX_LAG_SAMPLES grâce au reservoir, donc le calcul de la médiane est O(k log k) avec k = 64 quelle que soit la fréquence de la paire.
Identifiant de chaque extrémité
Chaque côté d'une paire est identifié par un CorrelationEndpoint :
pub struct CorrelationEndpoint {
pub finding_type: FindingType,
pub service: String,
pub template: String,
pub grouping_key: Option<String>,
pub grouping_value: Option<String>,
}Deux N+1 sur le même service mais avec des templates différents sont donc des endpoints distincts, et deux déploiements (valeurs de regroupement différentes) ne partagent jamais une paire.
Cap mémoire
- Deque d'horizon : environ
(lag_threshold_ms + ingest_skew_ms) x findings par secondeentrées d'une centaine d'octets, quelle que soitwindow_ms. - Registre des endpoints : une entrée par endpoint distinct vu dans la fenêtre, template compris, nettoyé une fois par pas de grille. Sans plafond : il croît avec le nombre d'endpoints distincts, donc des templates mal normalisés restent pendant toute la fenêtre.
- Paires : au plus
max_tracked_pairs, chacune bien sous 1 Ko avec le reservoir de 64 échantillons. - CPU : un parcours de l'horizon par finding entrant, aucune passe par tick sur les paires.
Configuration
[daemon.correlation]
enabled = true
window_minutes = 10
lag_threshold_ms = 5000
min_co_occurrences = 5
min_confidence = 0.7
max_tracked_pairs = 10000L'option enabled (défaut false) active la corrélation. setup_correlator construit alors le corrélateur et dérive ingest_skew_ms de trace_ttl_ms. Les résultats sont exposés via GET /api/correlations et figés sous correlations dans GET /api/export/report ; le flux stdout du daemon ne les porte jamais.
Corrections actionnables (suggestions framework-aware)
À partir de v0.4.2, un champ suggested_fix: Option<SuggestedFix> sur Finding porte une remédiation spécifique au framework qui va au-delà de la chaîne générique suggestion. Ce champ est peuplé par detect::suggestions::enrich après que les détecteurs per-trace ont retourné, à l'intérieur de detect(), et sur chaque finding slow cross-trace au moment où build_cross_trace_finding le construit, ce qui couvre la passe batch detect_slow_cross_trace et la fenêtre slow cross-batch du daemon.
La couverture a grandi en sept étapes :
- v1 : Java/JPA uniquement.
- v2 : Quarkus reactive et non-réactif, WebFlux, Helidon SE/MP, EF Core, Diesel et SeaORM.
- v3 : les sept anti-patterns qui retournaient jusque-là
suggested_fix = None(redundant_http,slow_sql,slow_http,excessive_fanout,chatty_service,pool_saturation,serialized_calls), plus Python (Django ORM, SQLAlchemy) avec détection de scope via le préfixeopentelemetry.instrumentation.*. - v4 : Go (GORM) et Node.js/TypeScript (Prisma) avec détection de scope via le préfixe
@opentelemetry/instrumentation-*et détection de langage via les extensions.go,.js,.ts. - v5 : Ruby (ActiveRecord) avec détection de scope via le préfixe vendeur
OpenTelemetry::Instrumentation::et détection de langage via l'extension.rb. - v6 : PHP (Laravel/Eloquent, Symfony/Doctrine) avec détection de scope via les scopes natifs
io.opentelemetry.contrib.php.*et détection de langage via l'extension.php. Le scopeio.opentelemetry.contrib.php.doctrineest spécifique à la base de données, il ne marque donc que les findings DB, maisio.opentelemetry.contrib.php.laravelest applicatif (il instrumente le noyau HTTP, la console, les files d'attente et le modèle Eloquent), il accompagne donc chaque finding Laravel. PhpLaravelEloquent porte donc des correctifs pour les 10 anti-patterns SQL et HTTP, tandis que PhpDoctrine ne porte que ceux SQL. Seul ce chemin est conscient du framework : dd-trace-php passé par ledatadogreceiverdu Collector n'expose aucun attribut de code PHP (le scope est unDatadogfixe), ces findings retombent donc surPhpGenericou restent non enrichis. - v7 : les deux types messaging (
n_plus_one_messaging,slow_messaging), indexés par technologie de broker et non par framework. La remédiation d'un anti-pattern de publication vit dans l'API de lot du client du broker (linger.ms,SendMessageBatch, une session JMS transactionnelle), que le framework applicatif ne nomme pas, d'où une seconde tableMESSAGING_FIXESindexée(FindingType, MessagingSystem). La détection lit le premier segment du template du finding, qui porte la valeurmessaging.systemdu span telle quelle (voir la note de normalisation dans 02 · Normalisation) : aucune heuristique de scope ni d'attribut de code, aucun accès aux spans. Kafka, RabbitMQ, SQS (aws_sqsplus le raccourcisqs), Pulsar, NATS et JMS sont couverts,activemqrenvoie vers le conseil JMS puisque c'est l'API cliente en jeu. Un système non listé (rocketmq,servicebus, ...) garde la suggestion générique.
Les nouvelles entrées s'appuient sur le tag générique *Generic du langage quand la recommandation est indépendante du framework, et réutilisent un tag spécifique quand l'écosystème fournit une primitive canonique à recommander. L'état actuel couvre Java, C# (.NET 8 à 10), Python, Rust, Go, Node.js, Ruby et PHP sur les 10 anti-patterns SQL et HTTP, chacun avec un fallback générique par langage, plus les deux anti-patterns messaging sur six technologies de broker.
Structure SuggestedFix
pub struct SuggestedFix {
pub pattern: String, // "n_plus_one_sql" miroir du finding.type parent
pub framework: String, // "java_jpa" ou "java_generic"
pub recommendation: String, // phrase courte et impérative
pub reference_url: Option<String>,
}Sérialisé en JSON comme objet imbriqué sous finding.suggested_fix, omis quand absent. Émis en SARIF sous result.fixes[0].description.text (forme description-only de l'objet fix SARIF 2.1.0). La CLI l'affiche comme ligne imbriquée Suggested fix: juste après la ligne générique Suggestion:.
Détecteur de framework
Le détecteur est une fonction pure sur des champs déjà présents sur Finding (instrumentation_scopes, code_location, service), tous peuplés au moment de la détection depuis les attributs OTel du span. Pas d'accès au niveau span, pas d'allocation supplémentaire. Il inspecte cinq signaux dans l'ordre, du plus fiable au moins fiable :
- Chaîne de scopes d'instrumentation, capturée à l'ingestion OTLP depuis le span d'origine et ses ancêtres (par exemple
io.opentelemetry.spring-data-3.0). Le plus fiable : le nom de scope est émis par l'agent quelle que soit la façon dont l'utilisateur nomme ses classes, il survit donc aux particularités de nommage du code utilisateur. Les scopes spécifiques aux vendeurs (io.quarkus.*,Microsoft.EntityFrameworkCore, le gem RubyOpenTelemetry::Instrumentation::ActiveRecord, les scopes PHPio.opentelemetry.contrib.php.doctrineetio.opentelemetry.contrib.php.laravel) sont vérifiés avant les scopes de la convention standardio.opentelemetry.*/opentelemetry.instrumentation.*/@opentelemetry/instrumentation-*. Go et Node sont volontairement absents des règles de scope par convention : leurs instrumentations utilisent des noms de scope natifs de l'écosystème (gorm.io/plugin/opentelemetry,@prisma/instrumentation), et la frontière de segment-utilisée pour les suffixes de version Java produirait des faux positifs sur les noms de paquets npm (pgcontreinstrumentation-pg-pool). - Langage déduit du préfixe de scope natif de l'écosystème. Quand la vérification de la chaîne de scopes échoue, le préfixe révèle quand même le langage (
github.com/= chemin de module Go,@opentelemetry/instrumentation-ou@prisma/= npm,Microsoft.EntityFrameworkCore/OpenTelemetry.Instrumentation.*= NuGet,OpenTelemetry::Instrumentation::= gem Ruby,io.opentelemetry.contrib.php.= PHP, puis tout autre scopeio.opentelemetry.= agent Java, par exempleio.opentelemetry.jdbcouio.opentelemetry.apache-httpclient-5.0). Le préfixe PHP est vérifié en premier pour que PHP garde ses scopes, et le préfixe Pythonopentelemetry.instrumentation.n'est pas revendiqué. Les règles de namespace de ce langage s'appliquent ensuite àcode_locationquand il est présent (un namespace Spring Data*Repositorydonnejava_jpa), puis les règles de nom de service de l'étape 5 quand elles désignent un framework de ce langage (helidon-mp-ordersdonnejava_helidon_mp), sinon le générique du langage s'applique, donc même un span sanscode.filepathnicode.namespacereçoit une suggestion adaptée au langage. - Namespace de
code_locationavec langage déduit du filepath (.java→ Java,.cs→ C#,.rs→ Rust,.py→ Python,.go→ Go,.js/.ts→ Node,.rb→ Ruby,.php→ PHP). Parcourt les règles de ce langage dans l'ordre déclaré ; fallback sur le générique du langage quand aucune règle ne matche. Les namespaces PHP utilisent des séparateurs\, reconnus par le même matcher de frontière de segment que.et::, et la dérivation de namespace à l'ingestion découpecode.function.namesur\quand il ne contient pas de point. - Namespace de
code_locationseul quand le filepath est absent : essaie les règles de chaque langage dans l'ordre et retourne le premier hit. Pas de fallback générique sur ce chemin parce que le langage ne peut pas être connu. - Nom de service en dernier recours, uniquement pour les noms de frameworks assez distinctifs pour éviter les faux positifs dans des noms de services arbitraires (par exemple
helidondanshelidon-se-svc). Confiance la plus basse, atteint seulement quand tous les signaux OTel sont absents.
Les findings structurels (serialized_calls, excessive_fanout, chatty_service, pool_saturation) n'ont pas de span d'origine unique : chacun porte les instrumentation_scopes et le code_location d'un appel représentatif qu'il référence déjà, à savoir le premier appel de la séquence sérialisée, le premier enfant du fan-out, le premier appel HTTP sortant de la trace chatty, le premier span SQL du service saturé. Le détecteur les lit comme pour tout autre finding. Chaque surface qui affiche code_location (Source: en CLI, source dans le rapport HTML, locations[] en SARIF) pointe alors sur cet appel représentatif, comme elle pointe sur le premier span du groupe pour un finding N+1.
Le match namespace est segment-boundary-aware des deux côtés : le hint doit commencer à la racine du namespace ou juste après un séparateur et doit se terminer à la fin du namespace ou juste avant un autre séparateur. Les caractères de séparation sont . (Java, C#) et :: (Rust). Exemples :
diesel::matchediesel::query_dsl::FilterDsletcrate::diesel::reexportmais pascrate::mydiesel::query(la boundary de tête protège le code utilisateur qui contient le hint).io.helidonmatcheio.helidon.webserver.Routingmais pasio.helidongrpc.Foo(la boundary de fin protège les paquets utilisateur dont le premier segment commence simplement par le hint).Microsoft.EntityFrameworkCorematcheMicrosoft.EntityFrameworkCore.Querymais pasMicrosoft.EntityFrameworkCoreCache.Provider.
Règles par langage
L'ordre compte au sein d'un langage : le premier framework qui matche gagne. Les hints JPA passent intentionnellement après ceux de Quarkus reactive parce que org.hibernate.reactive contient org.hibernate.
Chaque hint est de l'un de deux types. Substring matche un segment de package délimité par des frontières (toutes les règles ci-dessous sauf mention contraire). LastSegmentEndsWith matche uniquement le suffixe du dernier segment du namespace, pour les conventions de code utilisateur comme les repositories Spring Data où le package du framework n'apparaît jamais dans code.namespace (par exemple com.example.OrderRepository).
Java (JAVA_RULES) :
| Framework | Hints namespace |
|---|---|
JavaHelidonMp | io.helidon.microprofile |
JavaHelidonSe | io.helidon |
JavaQuarkusReactive | io.quarkus.hibernate.reactive, io.quarkus.panache.reactive, io.quarkus.reactive, org.hibernate.reactive, io.smallrye.mutiny |
JavaQuarkus | io.quarkus.hibernate.orm, io.quarkus.panache.common, io.quarkus |
JavaWebFlux | org.springframework.web.reactive, reactor.core |
JavaJpa | jakarta.persistence, javax.persistence, org.hibernate, org.springframework.data.jpa, plus les suffixes de dernier segment *Repository, *Repo, *Dao |
JavaGeneric (fallback) | (tout fichier .java sans les hints ci-dessus) |
JavaQuarkusReactive énumère explicitement ses sous-packages réactifs. Le catch-all io.quarkus appartient à JavaQuarkus (non-réactif), donc tout namespace Quarkus réactif doit matcher l'un des hints réactifs plus spécifiques en premier. Helidon MP doit passer avant Helidon SE parce que io.helidon.microprofile est un sous-package de io.helidon.
C# (CSHARP_RULES) :
| Framework | Hints namespace |
|---|---|
CsharpEfCore | Microsoft.EntityFrameworkCore, Pomelo.EntityFrameworkCore |
CsharpGeneric (fallback) | (tout fichier .cs sans les hints ci-dessus) |
Rust (RUST_RULES) :
| Framework | Hints namespace |
|---|---|
RustDiesel | diesel:: |
RustSeaOrm | sea_orm:: |
RustGeneric (fallback) | (tout fichier .rs sans les hints ci-dessus) |
Python (PYTHON_RULES) :
| Framework | Hints namespace |
|---|---|
PythonDjango | django |
PythonSqlAlchemy | sqlalchemy |
PythonGeneric (fallback) | (tout fichier .py sans les hints ci-dessus) |
Go (GO_RULES) :
| Framework | Hints namespace |
|---|---|
GoGorm | gorm |
GoGeneric (fallback) | (tout fichier .go sans les hints ci-dessus) |
Node.js (JS_RULES) :
| Framework | Hints namespace |
|---|---|
NodePrisma | prisma |
NodeGeneric (fallback) | (tout fichier .js/.ts/.jsx/.tsx/.mjs/.mts/.cjs/.cts sans les hints ci-dessus) |
Ruby (RUBY_RULES) :
| Framework | Hints namespace |
|---|---|
RubyActiveRecord | (aucun, atteint via le scope vendeur) |
RubyGeneric (fallback) | (tout fichier .rb, ou tout autre scope OTel Ruby) |
RUBY_RULES est vide : Ruby n'a pas de convention de namespace fiable dans code.namespace, donc RubyActiveRecord est atteint via le scope vendeur OpenTelemetry::Instrumentation::ActiveRecord, et tout autre scope OTel Ruby (les drivers pg/mysql2, Rack) ou un filepath .rb route vers RubyGeneric.
PHP (PHP_RULES) :
| Framework | Hints namespace (séparés par \) |
|---|---|
PhpLaravelEloquent | Illuminate\Database\Eloquent, App\Models |
PhpDoctrine | Doctrine\ORM, Doctrine\DBAL |
PhpGeneric (fallback) | (tout fichier .php, ou tout autre scope OTel PHP) |
Les frameworks PHP sont atteints en priorité via les scopes vendeurs io.opentelemetry.contrib.php.doctrine et io.opentelemetry.contrib.php.laravel. Les hints namespace sont le signal secondaire : le span SQL feuille de Laravel est scope PDO (code.function.name = "PDO::query") et n'expose aucun namespace applicatif, mais le span SQL propre à Doctrine porte un namespace Doctrine\DBAL\.... Tout autre scope OTel PHP (pdo, mongodb, curl, guzzle) ou un filepath .php route vers PhpGeneric.
Les frameworks Go et Node sont atteints via les hints namespace ci-dessus et le fallback langage-depuis-préfixe-de-scope, jamais via SCOPE_RULES : leurs instrumentations émettent des noms de scope natifs de l'écosystème (gorm.io/plugin/opentelemetry, @prisma/instrumentation) que les préfixes de la convention ne matchent pas. Voir la section détecteur de framework ci-dessus.
Table de mapping
Deux statics LazyLock<HashMap<_, SuggestedFix>>, et lookup_fix route sur le type de finding avant de lire le moindre signal de framework. Les anti-patterns protocolaires utilisent FIXES, indexée (FindingType, Framework) : un fallback générique par langage plus des entrées framework-specific. Les deux anti-patterns messaging utilisent MESSAGING_FIXES, indexée (FindingType, MessagingSystem), de sorte qu'un finding messaging n'atteint jamais la table framework et inversement. Un lookup FIXES qui manque le framework détecté est retenté avec le générique du langage de ce framework (JavaJpa vers JavaGeneric, CsharpEfCore vers CsharpGeneric), de sorte qu'un framework détecté ne donne jamais moins que le conseil du langage. Un échec sur le générique aussi, ou sur MESSAGING_FIXES, laisse suggested_fix à None. La couverture n'est volontairement pas une matrice complète langage x pattern. En particulier, n_plus_one_sql et redundant_sql passent surtout par des entrées framework-specific (un fallback générique N+1 SQL n'existe que pour Java, Go, Node, Ruby et PHP), donc un lookup générique pour ces patterns retourne None pour plusieurs langages. Ancres représentatives :
| Type de finding | Framework | Ancre de la recommandation |
|---|---|---|
NPlusOneSql | JavaJpa | JOIN FETCH ou @EntityGraph, Hibernate User Guide |
NPlusOneSql | JavaQuarkusReactive | Mutiny Session.fetch() + @NamedEntityGraph, guide Quarkus Hibernate Reactive |
NPlusOneSql | JavaQuarkus | JPQL/Panache JOIN FETCH, @EntityGraph ou Session.fetchProfile, guide Quarkus Hibernate ORM |
NPlusOneSql | JavaHelidonSe | Requête nommée Helidon DbClient avec JOIN ou binding JDBC :ids |
NPlusOneSql | JavaHelidonMp | JPA @EntityGraph ou JPQL JOIN FETCH (les entités MP sont gérées par JPA via Hibernate) |
NPlusOneHttp | JavaWebFlux | Flux.merge() / Flux.zip() pour le parallélisme ou endpoint batch |
NPlusOneHttp | JavaQuarkusReactive | Uni.combine().all().unis(...) pour le parallélisme, guide Mutiny combining |
NPlusOneHttp | JavaQuarkus | CompletableFuture.allOf sur ManagedExecutor, batch via Quarkus REST Client |
NPlusOneHttp | JavaHelidonSe | Helidon SE WebClient + Single.zip / Multi.merge pour le parallélisme ou endpoint batch |
NPlusOneHttp | JavaHelidonMp | MicroProfile Rest Client + CompletableFuture.allOf sur l'executor @ManagedExecutorConfig ou endpoint batch |
NPlusOneSql | JavaGeneric | JOIN unique / WHERE id IN (...), NamedParameterJdbcTemplate ou = ANY(?) |
NPlusOneHttp | JavaGeneric | Endpoint batch ou @Cacheable request-scoped |
RedundantSql | JavaQuarkusReactive | @CacheResult ou Uni.memoize().indefinitely() |
RedundantSql | JavaQuarkus | @CacheResult (extension cache Quarkus) ou déduplication HashMap @RequestScoped |
RedundantSql | JavaGeneric | Cache service-level (Caffeine, Spring Cache) |
NPlusOneSql | CsharpEfCore | .Include() / .ThenInclude(), .AsSplitQuery() pour l'explosion cartésienne |
RedundantSql | CsharpEfCore | IMemoryCache, DbContext scopé pour le short-circuit per-request |
NPlusOneHttp | CsharpGeneric | Task.WhenAll pour les appels parallèles, endpoint batch, response caching HttpClient |
NPlusOneSql | RustDiesel | belonging_to + grouped_by ou .inner_join / .left_join pour une seule query |
NPlusOneSql | RustSeaOrm | find_with_related / find_also_related ou QuerySelect::join |
RedundantSql | RustDiesel | Cache moka ou OnceCell request-local |
RedundantSql | RustSeaOrm | Cache moka ou OnceCell request-local |
NPlusOneHttp | RustGeneric | tokio::join! / futures::future::join_all pour le parallélisme ou endpoint batch |
NPlusOneSql | PythonDjango | Eager loading select_related() / prefetch_related() |
NPlusOneSql | PythonSqlAlchemy | joinedload() / subqueryload() ou un join() explicite |
RedundantSql | PythonDjango | Framework de cache Django (@cache_page / cache.get/set) ou déduplication request-local |
NPlusOneHttp | PythonGeneric | asyncio.gather() / ThreadPoolExecutor pour le parallélisme ou endpoint batch |
NPlusOneSql | GoGorm | Eager loading Preload() / Joins() |
NPlusOneSql | GoGeneric | JOIN unique / WHERE id IN (...), pgx ANY($1::int[]) |
NPlusOneHttp | GoGeneric | errgroup.Go pour les appels parallèles ou endpoint batch |
NPlusOneSql | NodePrisma | Eager loading include:{} ou findMany() avec un filtre WHERE id IN |
NPlusOneSql | NodeGeneric | JOIN unique / WHERE id IN (...), pg ANY($1::int[]) |
RedundantSql | NodeGeneric | node-cache ou une Map request-scoped, p-memoize pour les doublons concurrents |
NPlusOneSql | PhpLaravelEloquent | Eager loading with('relation') / load(...) ou batch whereIn('id', $ids) |
NPlusOneHttp | PhpLaravelEloquent | Http::pool(...) pour la concurrence ou endpoint batch (scope laravel applicatif, donc les patterns non-SQL mappent aussi) |
NPlusOneSql | PhpDoctrine | Fetch-join DQL (->leftJoin(...)->addSelect(...)) ou mapping fetch="EAGER" |
NPlusOneSql | PhpGeneric | un seul prepared statement avec une liste de placeholders IN (...) |
Chemin d'extension pour les contributeurs
Pour ajouter un nouveau framework :
- Étendre l'enum privé
Frameworkdansdetect/suggestions/mod.rs. - Choisir un langage et ajouter une entrée
(Framework, &[hint])au slice de règles de ce langage. Placer les frameworks plus spécifiques avant les moins spécifiques. - Ajouter des entrées à la static
FIXESpour chaque paire(FindingType, Framework)à mapper. - Ajouter des tests unitaires sous le module
testsdu même fichier.
Pour ajouter un nouveau langage :
- Étendre l'enum
Languageet ses méthodesrules()/generic(). - Ajouter le match d'extension de fichier dans
language_from_filepath. - Définir un nouveau slice
*_RULESet une variante générique fallback surFramework.
Aucun changement de câblage ailleurs : l'orchestrateur detect() appelle déjà suggestions::enrich à la fin de la passe de détection per-trace, build_cross_trace_finding l'appelle sur chaque finding slow cross-trace, et les rendus CLI / JSON / SARIF gèrent déjà un suggested_fix optionnel.
Signatures de findings et acquittements
acknowledgments.rs est la moitié batch/CI du workflow d'acquittement. Il charge .perf-sentinel-acknowledgments.toml, calcule une signature par finding, déplace les findings acquittés dans report.acknowledged_findings, puis réévalue la porte qualité sur ce qui reste. Le store runtime du daemon (daemon/ack.rs) partage le format de signature et est unioné avec le TOML au moment de la requête, le TOML l'emportant : c'est la référence immuable passée par revue de PR.
La signature est la pièce porteuse, parce que c'est à elle qu'est épinglée la décision "on n'y touche pas" d'un opérateur. Sa forme est <finding_type>:<service>:<endpoint_assaini>:<prefixe-sha256-du-template>.
- Pourquoi un hash uniquement sur le template. Le triplet
(finding_type, service, source_endpoint)est déjà dans la signature, le hash ne désambiguïse donc que les templates au sein d'un même triplet, une population minuscule. Ses 32 caractères hexadécimaux (128 bits) ne sont donc pas de la résistance aux collisions pour elle-même, mais une défense en profondeur contre un acquittement qui masquerait un autre finding après une refonte SQL ou un renommage de service. - Pourquoi
/et l'espace deviennent_. Pour que:reste un séparateur unique et non ambigu, qu'un opérateur peut découper aucut -d:dans un pipeline shell. - Pourquoi les caractères BiDi et invisibles sont retirés de
serviceetsource_endpoint(Trojan Source, CVE-2021-42574) : deux signatures qui s'affichent à l'identique ne doivent pas désigner deux entrées distinctes, sinon un acquittement devient invérifiable à la lecture.
La stabilité est un contrat, pas un détail d'implémentation. Toute modification du format, de l'assainissement ou de la largeur du hash invalide silencieusement chaque fichier d'acquittements déployé, et l'échec est silencieux dans le pire sens : les findings que l'opérateur avait acceptés réapparaissent, ou pire, un acquittement périmé continue de correspondre à autre chose. Une suite de tests dédiée épingle le format pour cette raison. Traitez un changement de signature comme une rupture exigeant un ré-acquittement, et dites-le dans le changelog.
Réévaluer la porte est le point central. Filtrer les findings sans relancer quality_gate laisserait analyze --ci en échec sur des findings que l'opérateur a explicitement acceptés, ce qui est toute la sémantique de "won't fix". La réévaluation tourne même quand rien n'a correspondu, pour que le champ de la porte soit toujours cohérent avec la liste finale de findings et non avec un instantané d'avant filtrage. apply vide aussi acknowledged_findings en premier, pour qu'un Report repassé dedans (un aller-retour JSON de référence) ne puisse pas accumuler de paires périmées.
L'expiration échoue en ouvert sur le finding, en fermé sur le fichier. Un acquittement dont expires_at est passé est inactif et son finding revient. Une date malformée, en revanche, interrompt l'exécution : une faute de frappe ne doit pas élargir silencieusement l'ensemble acquitté.