perf sentinelperf sentineldocs
FRENGitHub
Documentation / 04 · Détection

Algorithmes de détection

Ce sujet existe en version animée : voir l’étage Détection dans le synoptique →

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 mais parallélisables.

Pattern partagé : clés HashMap empruntées

Les trois détecteurs regroupent les spans par une clé composite. Les spans vivent dans la struct Trace, qui survit à la fonction de détection, donc nous pouvons emprunter depuis les spans au lieu de cloner :

rust
// 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

  1. Ignorer les spans SQL dont le template est une commande de session (normalize::sql::is_session_command)
  2. Grouper les spans par (&EventType, &str template)
  3. Ignorer les groupes avec moins de threshold occurrences (défaut 5)
  4. Compter les jeux de paramètres distincts via HashSet<&[String]>
  5. Ignorer les groupes avec moins de threshold paramètres distincts (mêmes paramètres = redondant, pas N+1)
  6. Calculer la fenêtre temporelle entre le plus ancien et le plus récent horodatage
  7. Ignorer les groupes où la fenêtre dépasse window_limit_ms (défaut 500ms)
  8. Assigner la sévérité : Critical si >= 10 occurrences, Warning sinon

Paramètres distincts via slices empruntés

rust
let distinct_params: HashSet<&[String]> = indices
    .iter()
    .map(|&i| trace.spans[i].params.as_slice())
    .collect();

Utiliser &[String] comme clé de HashSet apporte deux avantages :

  • 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

rust
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 horodatages dans un Vec :

rust
// Ancien (alloue) :
let timestamps: Vec<&str> = indices.iter().map(|&i| ...).collect();
let (w, min, max) = compute_window_and_bounds(&timestamps);

// 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

rust
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 horodatage. 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 horodatages min/max sont trouvés via comparaison de chaînes : if ts < min_ts { min_ts = ts; }. Cela fonctionne car les horodatages 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 remplacent les littéraux SQL par des 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. La vérification standard distinct_params >= threshold voit un seul slice de params vides et ne se déclenche jamais. Le détecteur de redondance 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 cinq signaux, évalués dans l'ordre :

  1. looks_sanitized : chaque span a un placeholder reconnu dans son template (?, $?, %s, @alpha, :alpha) et un vecteur params vide. Voir template_has_placeholder dans sanitizer_aware.rs pour la liste complète. Requis pour activer l'heuristique.
  2. has_orm_scope : au moins un scope d'instrumentation OpenTelemetry 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 comparés avec une vérification de frontière de mot (précédé et suivi d'un octet non alphanumérique), donc jpa ne se déclenche que sur spring-data-jpa et apparentés, jamais sur myappjpastats. Une correspondance positive est traitée comme une preuve forte de N+1.
  3. timing_variance_suggests_n_plus_one : quand le signal scope est absent, se rabattre sur le coefficient de variation de duration_us. Un vrai N+1 touche des lignes différentes avec des états de cache différents, donc l'écart est plus large. Les appels redondants en cache se regroupent étroitement. Seuil 0.5 empirique.
  4. sequential_siblings_indexed (mode Strict uniquement) : tous les spans partagent un même parent_span_id non vide et le groupe chaîne prev.end_us <= next.start_us après tri par horodatage de début. Les bornes sont calculées en microsecondes pour éviter la troncation silencieuse des durées sous-milliseconde. Se substitue à has_orm_scope sur les piles en accès direct au driver (Vert.x reactive PG, pgx, asyncpg, sqlx, Prisma queryRaw), qui n'émettent jamais de scope ORM.
  5. high_occurrence (mode Strict, toutes branches) : un nombre d'occurrences élevé (>= 3 x n_plus_one_threshold, par défaut 15) est à la fois un signal primaire et un signal corroboratif. Sous la garde looks_sanitized (params vides, template avec ?), 15+ templates sanitisés identiques dans une seule 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 en redundant_sql.

Les quatre modes d'émission (Auto, Strict, Always, Never) sont documentés dans Configuration § "sanitizer_aware_classification" avec leurs compromis précision/rappel.

Le détail HTML rend cette décision vérifiable sans la modifier. Les N+1 directs portent le libellé direct et les groupes récupérés le libellé heuristic. 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 de span 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 contribuent à parts égales aux avoidable_io_ops GreenOps, et 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 ramène toujours les IDs de path à {id}/{uuid}, et 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 filtre looks_sanitized une boucle de polling active ou un appel répété servi par un CDN serait promu en n_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 masque la query string avant l'export. Sur .NET, url.full porte une query masquée par défaut (l'activité System.Net.Http du runtime à partir de .NET 9, le paquet OpenTelemetry.Instrumentation.Http avant), donc chaque appel porte un url.full identique à l'octet 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

rust
HashMap<(&EventType, &str, &[String]), Vec<usize>>

La clé en trois parties inclut le slice complet des paramètres, si bien que deux spans avec le même template mais des paramètres différents tombent dans des groupes différents. 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 instructions restent comptées 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 où 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 la vérification 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 sans aucun littéral, comme SELECT NOW(), n'ont aucun placeholder et n'activent pas l'heuristique. Elle évalue ensuite deux signaux indépendants :

  1. Marqueur de scope d'instrumentation (confiance élevée). Les chaînes instrumentation_scopes par 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 sans ORM comme sqlx (Go/Rust), pgx, asyncpg et le client réactif Vert.x PG sont exclus : leurs patterns n+1 sont pris en charge par le signal "siblings séquentiels". Une correspondance fait basculer le verdict en LikelyNPlusOne.
  2. Repli sur la variance temporelle (confiance moyenne). En l'absence de marqueur ORM, l'heuristique calcule le coefficient de variation (écart-type / moyenne) des duration_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 de 0.5 est 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 OU nombre d'occurrences élevé) plus un signal corroboratif (variance OU 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 :

  • auto privilégie le rappel : il capture tous les N+1 induits par un ORM parce que le scope ORM seul déclenche le verdict. Le prix est d'absorber des findings redundant_sql légitimes sur les stacks Spring Data / EF Core (un findById(sameId) appelé en boucle et servi depuis le cache de lignes bascule en n_plus_one_sql).
  • strict privilégie la précision : il préserve les findings redundant_sql sur les requêtes identiques en cache, en nombre modéré (sous la barre 3 x threshold), parce que le signal de variance temporelle reste bas. 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 findings redundant_sql exploitables 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 entièrement court-circuitée.

Le signal de variance temporelle (timing_variance_suggests_n_plus_one, coefficient de variation > 0,5) est réglé pour un 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 est le seul corroborateur sur la branche ORM en dessous de la barre de haute occurrence. Il a aussi 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 lectures Doctrine identiques servies depuis le cache sur un worker PHP-FPM ont mesuré un CV proche de 0,75 une fois le runner chargé. Ce CV a franchi la barre et transformé 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

rust
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

rust
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 détecteurs s'exécutent séquentiellement sur chaque trace. Bien qu'ils puissent en théorie 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, plusieurs passes O(n) sont négligeables.

Détection de fanout

Algorithme

  1. Regrouper les spans par parent_span_id
  2. Ignorer les groupes où le parent a max_fanout ou moins d'enfants (défaut 20)
  3. Pour chaque parent dépassant le seuil, émettre un finding ExcessiveFanout
  4. Sévérité : Warning si > max_fanout, Critical si > 3x max_fanout
  5. Template : celui du parent, ou le template enfant le plus fréquent (le plus petit en cas d'égalité) quand le parent n'est pas dans la trace, cas courant quand le span serveur n'est pas un événement capturé. Jamais l'identifiant du span parent, qui change à chaque trace et donnerait à chaque occurrence sa propre signature

Le détecteur de fanout utilise un index de spans HashMap<&str, usize> pour retrouver le parent en O(1) et compute_window_and_bounds pour calculer l'étendue chronologique des horodatages des enfants en une seule passe.

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 (trop d'opérations enfants par parent) qui nécessite une optimisation architecturale, pas une élimination d'I/O. La boucle de déduplication et l'enrichissement green_impact utilisent tous deux FindingType::is_avoidable_io() pour en décider. La règle a donc une seule source de vérité.

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é. Cela complète la détection lente par trace en identifiant les templates lents de façon constante sur plusieurs requêtes.

  • Seuls les spans qui dépassent le seuil sont collectés (préfiltre pour la performance)
  • Seuls les templates apparaissant dans au moins 2 traces distinctes sont rapportés (les cas à trace unique relèvent de la détection par trace)
  • Le calcul des percentiles utilise la méthode du rang le plus proche via div_ceil

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, regroupement, 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, regroupement) 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 est donc fusionné avec eux dans le findings store. pattern.occurrences et 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_id du 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 à 0 désactive la fonctionnalité.

Détection des services bavards

Algorithme

  1. Filtrer les spans pour ne garder que les appels HTTP sortants (type: http_out)
  2. Compter le total de spans HTTP sortants dans la trace
  3. Si ce nombre est ≤ chatty_service_min_calls (défaut 15), ignorer
  4. Collecter les endpoints normalisés les plus appelés pour le message de suggestion
  5. Assigner la sévérité : Warning si > seuil, Critical si > 3x seuil
Input:  trace with N spans
Output: 0 or 1 ChattyService finding

filter spans where type == http_out
if count(http_spans) <= chatty_service_min_calls:
    return []

group http_spans by normalized template
sort groups by count descending
top_endpoints = first 5 groups

severity = Critical if count > 3 * threshold else Warning
emit finding with top_endpoints in suggestion

Complexité : O(n) pour filtrer et compter, O(k log k) pour trier les groupes, où k est le nombre de templates distincts. Comme k << n en pratique, cela revient à O(n).

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.

Pas dans le ratio de gaspillage

Les findings de services bavards ont green_impact.estimated_extra_io_ops = 0. Le détecteur signale un problème architectural (trop d'appels inter-services par requête), pas une occasion de regroupement. Les appels peuvent tous être nécessaires. Le problème est que la frontière du service est trop fine. FindingType::is_avoidable_io() retourne false pour ChattyService.

Détection de saturation du pool de connexions

Algorithme

  1. Filtrer les spans pour ne garder que le SQL (type: sql)
  2. Regrouper les spans SQL par nom de service
  3. Pour chaque groupe de service, calculer le pic de concurrence par balayage (sweep line)
  4. Si le pic de concurrence est < pool_saturation_concurrent_threshold (défaut 10), ignorer
  5. La sévérité est toujours Warning, quel que soit le pic : à la différence du fanout et du service bavard, ce détecteur n'a pas de palier Critical
Input:  trace with N spans, grouped by service
Output: 0 or more PoolSaturation findings (one per service)

for each service in sql_spans_by_service:
    events = []
    for span in service_spans:
        start = parse_timestamp(span.timestamp)
        end = start + span.duration_us
        events.push((start, +1))
        events.push((end, -1))

    sort events by timestamp, with -1 before +1 on ties
    current = 0
    peak = 0
    for (ts, delta) in events:
        current += delta
        peak = max(peak, current)

    if peak >= pool_saturation_concurrent_threshold:
        emit finding

Complexité : O(n log n) pour le tri, O(n) pour le balayage. Total : O(n log n) par groupe de service.

Départage des égalités dans le balayage

Quand un span se termine et qu'un autre commence exactement à la même microseconde, l'algorithme traite l'événement de fin (-1) avant l'événement de début (+1). Cela évite de gonfler le pic de concurrence quand les spans sont seulement adjacents sans se chevaucher.

Pas dans le ratio de gaspillage

Les findings de saturation du pool ont green_impact.estimated_extra_io_ops = 0. Une concurrence élevée ne relève pas des I/O évitables. Elle signale une contention possible sur le pool de connexions à la base de données, qui relève du réglage ou de l'architecture. FindingType::is_avoidable_io() retourne false pour PoolSaturation.

Détection des appels sérialisés

Algorithme

  1. Écarter les spans SQL frères dont le template est une commande de session : un COMMIT ne peut pas sortir de la chaîne, et le compter gonfle à la fois le nombre de maillons et le total séquentiel
  2. Grouper les spans frères par parent_span_id
  3. Pour chaque groupe, trier les enfants par temps de fin (croissant)
  4. Trouver la plus longue sous-séquence non chevauchante via programmation dynamique (Weighted Interval Scheduling avec poids unitaires)
  5. Si la séquence optimale a >= serialized_min_sequential (défaut 3) spans avec des templates distincts, émettre un finding
  6. Sévérité : toujours Info (heuristique, risque inhérent de faux positifs)
Input:  trace with N spans, grouped by parent_span_id
Output: 0 or more SerializedCalls findings

for each parent_id in spans_by_parent:
    children = spans with this parent_id
    if len(children) < serialized_min_sequential:
        skip

    sort children by end_time ascending
    
    // 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 children[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).
    
    if len(selected) >= serialized_min_sequential
       AND distinct_templates(selected) > 1:
        emit finding for selected sequence

Complexité : 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 : total_sequential_duration - max_individual_duration. 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 dans le meilleur des cas, 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 réduit la latence mais pas le nombre total d'opérations I/O. FindingType::is_avoidable_io() retourne false pour SerializedCalls.

Orchestration de la détection (mise à jour)

rust
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 daemon (perf-sentinel watch), Perf Sentinel voit les findings de toutes les traces au fil du temps. Le CrossTraceCorrelator détecte les co-occurrences temporelles récurrentes entre findings de services différents : "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 se rabat 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.

État interne

rust
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, un VecDeque dans 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é et counted_targets, les cibles pour lesquelles cette occurrence a déjà compté comme source. Une entrée sort dès que ingest_ms + lag_threshold_ms + ingest_skew_ms < now_ms. L'horizon ne dépend que du délai et du décalage, jamais de window_ms : une fenêtre de 24 h garde le même deque qu'une fenêtre de 10 min. Le décalage vaut 2 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. Chaque CorrelationEndpoint distinct (type de finding, service, template, regroupement) est stocké une seule fois derrière un Arc. Le deque, les clés de paire et counted_targets partagent 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é par PairKey (source, cible), deux Arc internés. Chaque PairState contient le compteur de co-occurrences, un réservoir borné de délais, un compteur total_observations, un état PRNG SplitMix64, first_seen_ms/last_seen_ms sur 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.

L'algorithme ingest()

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 :

  1. Avancer la grille. now_idx = max(now_idx, now_ms / half_window).
  2. Évincer l'horizon. Retirer les occurrences en tête tant qu'elles dépassent lag_threshold_ms + ingest_skew_ms en temps d'ingestion. L'éviction ne touche aucun compteur.
  3. Nettoyer les paires obsolètes. Une passe HashMap::retain retire les paires dont last_seen_ms est plus ancien que window_ms.
  4. 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).
  5. 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.
  6. Compter une fois par occurrence source. Chaque correspondance rafraîchit last_seen_ms et 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 réservoir de délais ne bougent que si l'occurrence source n'a pas encore compté pour cet endpoint cible, ce que suit le counted_targets de 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.
  7. 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 de select_nth_unstable sur 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.

Le filtre active_correlations()

Pour chaque paire, avec tous les compteurs lus à now_idx :

  • co_occurrence_count est le compteur fenêtré de la paire. Les paires sous min_co_occurrences (défaut 5) sont écartées.
  • source_total_occurrences est 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 sous min_confidence (défaut 0.7) sont écartées.

median_lag_ms est la médiane du réservoir, 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 réservoir a de la place, ajout inconditionnel.
  • Une fois plein, tirer r uniformément dans [0, total_observations) via SplitMix64. Si r < MAX_LAG_SAMPLES, remplacer lags_ms[r]. Conditionnellement à r < k, r est lui-même uniforme dans [0, k), donc le choix de l'emplacement est non biaisé sans tirage PRNG supplémentaire.

Le PRNG est un état SplitMix64 par PairState, initialisé à 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 processus et rendrait le corrélateur non déterministe entre exécutions). Deux exécutions du daemon rejouant le même fichier de traces produisent des échantillons de réservoir identiques et donc des médianes identiques.

Calcul de la médiane

La fonction utilitaire 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 réservoir, donc le calcul de la médiane est O(k log k) avec k = 64 quelle que soit la fréquence de la paire.

Gestion de la mémoire

  • Deque d'horizon : environ (lag_threshold_ms + ingest_skew_ms) x findings par seconde entrées d'une centaine d'octets, quelle que soit window_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 réservoir de 64 échantillons.
  • CPU : un parcours de l'horizon par finding entrant, aucune passe par tick sur les paires.

Point d'intégration

Le corrélateur est créé par setup_correlator quand [daemon.correlation] enabled vaut true (défaut false), avec ingest_skew_ms dérivé de trace_ttl_ms. Il est enveloppé dans un Arc<Mutex<CrossTraceCorrelator>> et passé à process_traces. Une fois les findings produits et poussés dans le FindingsStore, la méthode ingest() du corrélateur est appelée avec les findings et l'horodatage courant.

Exclusion du mode batch

Le corrélateur n'est pas utilisé en mode batch (perf-sentinel analyze). La corrélation cross-trace exige un flux de findings dans le temps pour détecter des motifs récurrents. Une exécution batch unique traite en général un ensemble fixe de traces, sans la dimension temporelle nécessaire à une corrélation pertinente.

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 le retour des détecteurs par trace, à 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éfixe opentelemetry.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 scope io.opentelemetry.contrib.php.doctrine est spécifique à la base de données, il ne marque donc que les findings DB, mais io.opentelemetry.contrib.php.laravel est 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 le datadogreceiver du Collector n'expose aucun attribut de code PHP (le scope est un Datadog fixe), ces findings retombent donc sur PhpGeneric ou 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 table MESSAGING_FIXES indexée (FindingType, MessagingSystem). La détection lit le premier segment du template du finding, qui porte la valeur messaging.system du 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_sqs plus le raccourci sqs), Pulsar, NATS et JMS sont couverts, et activemq renvoie 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 repli générique par langage, plus les deux anti-patterns messaging sur six technologies de broker.

Structure SuggestedFix

rust
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 comme chaîne result.properties.suggestedFix. Le tableau SARIF fixes[] n'est pas utilisé, son schéma exige artifactChanges, un patch source que Perf Sentinel ne produit pas. 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, et pattern.template pour la règle Hibernate ci-dessous), 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 :

  1. 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, donc les particularités de nommage du code utilisateur ne l'affectent pas. Les scopes spécifiques aux vendeurs (io.quarkus.*, Microsoft.EntityFrameworkCore, le gem Ruby OpenTelemetry::Instrumentation::ActiveRecord, les scopes PHP io.opentelemetry.contrib.php.doctrine et io.opentelemetry.contrib.php.laravel) sont vérifiés avant les scopes de la convention standard io.opentelemetry.* / opentelemetry.instrumentation.* / @opentelemetry/instrumentation-*. Go et Node sont 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 (pg contre instrumentation-pg-pool).
  2. 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 scope io.opentelemetry. = agent Java, par exemple io.opentelemetry.jdbc ou io.opentelemetry.apache-httpclient-5.0, et org.springframework = Spring Boot tracé par Micrometer Observation, dont le tracer range chaque span sous org.springframework.boot). Le préfixe PHP est vérifié en premier pour que PHP garde ses scopes, et le préfixe Python opentelemetry.instrumentation. n'est pas revendiqué. Les règles de namespace de ce langage s'appliquent ensuite à code_location quand il est présent (un namespace Spring Data *Repository donne java_jpa), puis les règles de nom de service de l'étape 5 quand elles désignent un framework de ce langage (helidon-mp-orders donne java_helidon_mp). Sinon le générique du langage s'applique, donc même un span sans code.filepath ni code.namespace reçoit une suggestion adaptée au langage. Pour Java, ce générique cède la place à java_jpa quand la requête est un SELECT qui porte les alias de table de Hibernate 6 (select d1_0.id from dossier d1_0) : un span peut ne nommer ni Hibernate ni le repository, la requête désigne quand même Hibernate. Un UPDATE ou un DELETE en masse garde le générique, car les correctifs JPA portent sur le chargement, de même qu'une requête tracée sous le client SQL de Vert.x, où Hibernate Reactive produit les mêmes alias. L'alias doit aussi être déclaré après sa table, donc un schéma simplement nommé comme tenant1_0 ne compte pas. L'étape 3 applique la même règle.
  3. Namespace de code_location avec 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é et se rabat sur le générique du langage quand aucune règle ne correspond. Les namespaces PHP utilisent des séparateurs \, reconnus par la même vérification de frontière de segment que . et ::, et la dérivation de namespace à l'ingestion découpe code.function.name sur \ quand il ne contient pas de point.
  4. Namespace de code_location seul quand le filepath est absent : essaie les règles de chaque langage dans l'ordre et retourne la première correspondance. Pas de repli générique sur ce chemin parce que le langage ne peut pas être connu.
  5. 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 helidon dans helidon-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à : 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.

La correspondance de namespace respecte les frontières de segment des deux côtés : l'indication 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:: correspond à diesel::query_dsl::FilterDsl et à crate::diesel::reexport mais pas à crate::mydiesel::query (la frontière de tête protège le code utilisateur qui contient l'indication).
  • io.helidon correspond à io.helidon.webserver.Routing mais pas à io.helidongrpc.Foo (la frontière de fin protège les paquets utilisateur dont le premier segment commence simplement par l'indication).
  • Microsoft.EntityFrameworkCore correspond à Microsoft.EntityFrameworkCore.Query mais pas à Microsoft.EntityFrameworkCoreCache.Provider.

Règles par langage

L'ordre compte au sein d'un langage : le premier framework qui correspond gagne. Les indications JPA passent après celles de Quarkus reactive parce que org.hibernate.reactive contient org.hibernate.

Chaque indication est de l'un de deux types. Substring correspond à un segment de package délimité par des frontières (toutes les règles ci-dessous sauf mention contraire). LastSegmentEndsWith ne compare que 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) :

FrameworkIndications de namespace
JavaHelidonMpio.helidon.microprofile
JavaHelidonSeio.helidon
JavaQuarkusReactiveio.quarkus.hibernate.reactive, io.quarkus.panache.reactive, io.quarkus.reactive, org.hibernate.reactive, io.smallrye.mutiny
JavaQuarkusio.quarkus.hibernate.orm, io.quarkus.panache.common, io.quarkus
JavaWebFluxorg.springframework.web.reactive, reactor.core
JavaJpajakarta.persistence, javax.persistence, org.hibernate, org.springframework.data.jpa, plus les suffixes de dernier segment *Repository, *Repo, *Dao
JavaGeneric (repli)(tout fichier .java sans les indications ci-dessus, sauf si son SELECT porte des alias Hibernate : alors JavaJpa)

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 correspondre en premier à l'une des indications réactives plus spécifiques. Helidon MP doit passer avant Helidon SE parce que io.helidon.microprofile est un sous-package de io.helidon.

Note sur Helidon MP et JPA : les entités Helidon MP sont gérées par JPA via Hibernate. Un span JDBC OTel typique sur du code Helidon MP porte code.namespace = jakarta.persistence.* ou org.hibernate.*, ce qui mène à JavaJpa (et non JavaHelidonMp). La règle JavaHelidonMp se déclenche quand le span vient de la plomberie Helidon MP elle-même (ressources REST, conteneurs CDI, MicroProfile Rest Client). Pour les findings base de données sur les applications Helidon MP, la recommandation JavaJpa s'applique.

C# (CSHARP_RULES) :

FrameworkIndications de namespace
CsharpEfCoreMicrosoft.EntityFrameworkCore, Pomelo.EntityFrameworkCore
CsharpGeneric (repli)(tout fichier .cs sans les indications ci-dessus)

Rust (RUST_RULES) :

FrameworkIndications de namespace
RustDieseldiesel::
RustSeaOrmsea_orm::
RustGeneric (repli)(tout fichier .rs sans les indications ci-dessus)

Python (PYTHON_RULES) :

FrameworkIndications de namespace
PythonDjangodjango
PythonSqlAlchemysqlalchemy
PythonGeneric (repli)(tout fichier .py sans les indications ci-dessus)

Go (GO_RULES) :

FrameworkIndications de namespace
GoGormgorm
GoGeneric (repli)(tout fichier .go sans les indications ci-dessus)

Node.js (JS_RULES) :

FrameworkIndications de namespace
NodePrismaprisma
NodeGeneric (repli)(tout fichier .js/.ts/.jsx/.tsx/.mjs/.mts/.cjs/.cts sans les indications ci-dessus)

Ruby (RUBY_RULES) :

FrameworkIndications de namespace
RubyActiveRecord(aucun, atteint via le scope vendeur)
RubyGeneric (repli)(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) :

FrameworkIndications de namespace (séparées par \)
PhpLaravelEloquentIlluminate\Database\Eloquent, App\Models
PhpDoctrineDoctrine\ORM, Doctrine\DBAL
PhpGeneric (repli)(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 indications de 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 indications de namespace ci-dessus et le repli 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) auxquels les préfixes de la convention ne correspondent 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 repli générique par langage plus des entrées spécifiques au framework. 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. Une recherche dans FIXES qui manque le framework détecté est retentée 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 ne vise pas une matrice complète langage x pattern. En particulier, n_plus_one_sql et redundant_sql passent surtout par des entrées spécifiques au framework (un repli générique N+1 SQL n'existe que pour Java, Go, Node, Ruby et PHP), donc une recherche générique pour ces patterns retourne None pour plusieurs langages. Ancres représentatives :

Type de findingFrameworkAncre de la recommandation
NPlusOneSqlJavaJpaJOIN FETCH ou @EntityGraph, Hibernate User Guide
NPlusOneSqlJavaQuarkusReactiveMutiny Session.fetch() + @NamedEntityGraph, guide Quarkus Hibernate Reactive
NPlusOneSqlJavaQuarkusJPQL/Panache JOIN FETCH, @EntityGraph ou Session.fetchProfile, guide Quarkus Hibernate ORM
NPlusOneSqlJavaHelidonSeRequête nommée Helidon DbClient avec JOIN ou binding JDBC :ids
NPlusOneSqlJavaHelidonMpJPA @EntityGraph ou JPQL JOIN FETCH (les entités MP sont gérées par JPA via Hibernate)
NPlusOneHttpJavaWebFluxFlux.merge() / Flux.zip() pour le parallélisme ou endpoint batch
NPlusOneHttpJavaQuarkusReactiveUni.combine().all().unis(...) pour le parallélisme, guide Mutiny combining
NPlusOneHttpJavaQuarkusCompletableFuture.allOf sur ManagedExecutor, batch via Quarkus REST Client
NPlusOneHttpJavaHelidonSeHelidon SE WebClient + Single.zip / Multi.merge pour le parallélisme ou endpoint batch
NPlusOneHttpJavaHelidonMpMicroProfile Rest Client + CompletableFuture.allOf sur l'executor @ManagedExecutorConfig ou endpoint batch
NPlusOneSqlJavaGenericJOIN unique / WHERE id IN (...), NamedParameterJdbcTemplate ou = ANY(?)
NPlusOneHttpJavaGenericEndpoint batch ou @Cacheable request-scoped
RedundantSqlJavaQuarkusReactive@CacheResult ou Uni.memoize().indefinitely()
RedundantSqlJavaQuarkus@CacheResult (extension cache Quarkus) ou déduplication HashMap @RequestScoped
RedundantSqlJavaGenericCache service-level (Caffeine, Spring Cache)
NPlusOneSqlCsharpEfCore.Include() / .ThenInclude(), .AsSplitQuery() pour l'explosion cartésienne
RedundantSqlCsharpEfCoreIMemoryCache, DbContext scopé pour le short-circuit per-request
NPlusOneHttpCsharpGenericTask.WhenAll pour les appels parallèles, endpoint batch, response caching HttpClient
NPlusOneSqlRustDieselbelonging_to + grouped_by ou .inner_join / .left_join pour une seule query
NPlusOneSqlRustSeaOrmfind_with_related / find_also_related ou QuerySelect::join
RedundantSqlRustDieselCache moka ou OnceCell request-local
RedundantSqlRustSeaOrmCache moka ou OnceCell request-local
NPlusOneHttpRustGenerictokio::join! / futures::future::join_all pour le parallélisme ou endpoint batch
NPlusOneSqlPythonDjangoEager loading select_related() / prefetch_related()
NPlusOneSqlPythonSqlAlchemyjoinedload() / subqueryload() ou un join() explicite
RedundantSqlPythonDjangoFramework de cache Django (@cache_page / cache.get/set) ou déduplication request-local
NPlusOneHttpPythonGenericasyncio.gather() / ThreadPoolExecutor pour le parallélisme ou endpoint batch
NPlusOneSqlGoGormEager loading Preload() / Joins()
NPlusOneSqlGoGenericJOIN unique / WHERE id IN (...), pgx ANY($1::int[])
NPlusOneHttpGoGenericerrgroup.Go pour les appels parallèles ou endpoint batch
NPlusOneSqlNodePrismaEager loading include:{} ou findMany() avec un filtre WHERE id IN
NPlusOneSqlNodeGenericJOIN unique / WHERE id IN (...), pg ANY($1::int[])
RedundantSqlNodeGenericnode-cache ou une Map request-scoped, p-memoize pour les doublons concurrents
NPlusOneSqlPhpLaravelEloquentEager loading with('relation') / load(...) ou batch whereIn('id', $ids)
NPlusOneHttpPhpLaravelEloquentHttp::pool(...) pour la concurrence ou endpoint batch (scope laravel applicatif, donc les patterns non-SQL mappent aussi)
NPlusOneSqlPhpDoctrineFetch-join DQL (->leftJoin(...)->addSelect(...)) ou mapping fetch="EAGER"
NPlusOneSqlPhpGenericun seul prepared statement avec une liste de placeholders IN (...)

Chemin d'extension pour les contributeurs

Pour ajouter un nouveau framework :

  1. Étendre l'enum privé Framework dans detect/suggestions/mod.rs.
  2. 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.
  3. Ajouter des entrées à la static FIXES de detect/suggestions/fixes.rs pour chaque paire (FindingType, Framework) à mapper.
  4. Ajouter des tests unitaires dans detect/suggestions/tests.rs.

Pour ajouter un nouveau langage :

  1. Étendre l'enum Language et ses méthodes rules() / generic().
  2. Ajouter le match d'extension de fichier dans language_from_filepath.
  3. Définir un nouveau slice *_RULES et une variante générique de repli sur Framework.

Aucun changement de câblage ailleurs : l'orchestrateur detect() appelle déjà suggestions::enrich à la fin de la passe de détection par 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 fusionné 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.

L'essentiel de la conception tient dans la signature, parce que c'est à elle qu'est épinglée la décision won't fix d'un opérateur. Sa forme est <finding_type>:<service>:<sanitized_endpoint>:<sha256-prefix-of-template>.

  • Pourquoi un hash uniquement sur le template. Le triplet (finding_type, service, sanitized_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) dépassent donc largement ce que la résistance aux collisions exige dans cette population : ils sont 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 au cut -d: dans un pipeline shell.
  • Pourquoi les caractères BiDi et invisibles sont retirés de service et source_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é. 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.

La porte est réévaluée après filtrage. 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, et bruyamment 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é.