CLI, configuration et profil release
Conception du CLI
Le CLI (sentinel-cli) est léger. Il parse les arguments avec clap et délègue aux fonctions de sentinel-core. Dix-neuf sous-commandes sont disponibles. Treize sont toujours présentes : analyze, explain, demo, bench, pg-stat, calibrate, report, diff, disclose, hash-bake, verify-hash, completions et man. Les six autres sont conditionnées par feature : watch, query et ack (daemon), inspect (tui), tempo (tempo) et jaeger-query (jaeger-query).
Analyze : rapport coloré par défaut, JSON avec --ci
perf-sentinel analyze affiche un rapport coloré dans le terminal en mode interactif (sans --ci). C'est la sortie que les humains voient en utilisant l'outil localement. Avec --ci, la sortie passe en JSON structuré pour la consommation par les machines et le processus sort avec le code 1 si le quality gate échoue.
Cela suit la convention d'outils comme cargo test (sortie colorée par défaut, --format json pour le CI).
Le flag --format offre un contrôle explicite sur le format de sortie : text (terminal coloré, défaut), json (rapport structuré) ou sarif (SARIF v2.1.0 pour le code scanning). Avec --ci sans --format, la sortie est en JSON par défaut pour la rétrocompatibilité.
Explain : vue arborescente par trace
perf-sentinel explain --input FILE --trace-id ID construit un arbre à partir des relations parent_span_id et annote les findings en ligne. Il exécute uniquement les détecteurs par trace (N+1, redondant, lent, fanout). Les findings cross-trace par percentile ne sont pas inclus.
Formats de sortie : --format text (arbre coloré avec caractères Unicode de tracé de cadres, défaut) ou --format json (structure JSON imbriquée). Les deux incluent une garde MAX_TREE_DEPTH de 256 niveaux pour éviter un débordement de pile sur les traces profondément imbriquées.
Bench : clone par itération
for _ in 0..iterations {
let batch = events.clone();
let start = std::time::Instant::now();
let _ = pipeline::analyze(batch, &config);
durations_ns.push(start.elapsed().as_nanos() as u64);
}Chaque itération clone le lot d'entrée avant le démarrage de son chrono, pour que le benchmark ne mesure que la performance de pipeline::analyze, pas le surcoût de Vec<SpanEvent>::clone. Puisque analyze consomme son entrée (Vec<SpanEvent> est déplacé), chaque itération a besoin de sa propre copie. Le clone dans la boucle, depuis la 0.8.7, garde deux copies de l'entrée en mémoire au lieu de iterations copies.
Calcul des percentiles
per_event_ns.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
let p50_idx = ((per_event_ns.len() as f64 * 0.50).ceil() as usize).saturating_sub(1);
let p99_idx = ((per_event_ns.len() as f64 * 0.99).ceil() as usize)
.saturating_sub(1)
.min(per_event_ns.len() - 1);Le calcul d'index basé sur le plafond suit la méthode du rang le plus proche pour les percentiles. Le .saturating_sub(1) convertit du rang basé sur 1 vers l'index basé sur 0. Le .min(len - 1) empêche l'accès hors limites quand ceil arrondit à len.
Débit à partir des nanosecondes
let elapsed_nanos: u64 = durations_ns.iter().sum();
let total_seconds = elapsed_nanos as f64 / 1_000_000_000.0;
let throughput = if total_seconds > 0.0 { total_events / total_seconds } else { 0.0 };Le débit est calculé à partir de la précision nanoseconde (pas milliseconde) pour éviter la division par zéro quand les itérations se terminent en moins de 1ms. Le champ total_elapsed_ms dans la sortie est dérivé des nanosecondes pour l'affichage.
Mesure RSS
Mesure mémoire spécifique à la plateforme :
| Plateforme | Méthode | Unité |
|---|---|---|
| Linux | /proc/self/status -> ligne VmRSS | Ko (converti en octets) |
| macOS | libc::getrusage(RUSAGE_SELF) -> ru_maxrss | Octets (sur macOS) |
| Windows | Non implémenté | Retourne None |
L'implémentation macOS utilise unsafe pour l'appel FFI libc::getrusage. C'est justifié : il n'existe pas d'API Rust safe pour cet appel système et la fonction est bien documentée dans POSIX. La valeur de retour est vérifiée (if ret == 0) avant d'utiliser le résultat.
Sortie colorée avec détection TTY
let is_tty = force_color || std::io::stdout().is_terminal();
let (bold, cyan, red, yellow, green, dim, reset) = if is_tty {
("\x1b[1m", "\x1b[36m", "\x1b[31m", "\x1b[33m", "\x1b[32m", "\x1b[2m", "\x1b[0m")
} else {
("", "", "", "", "", "", "")
};Les codes d'échappement ANSI sont supprimés quand stdout n'est pas un terminal (ex. redirigé vers un fichier ou jq). Le paramètre force_color permet aux tests d'exercer le chemin coloré sans vrai TTY. Cela suit la convention d'outils comme ls --color=auto et la sortie de rustc.
Surcharge pour --output. La sonde stdout().is_terminal() ci-dessus ignore le writer réel : une CLI lancée depuis un terminal interactif avec --output path.txt redirige la sortie vers un File, mais la palette colorée serait quand même choisie et laisserait fuir des octets d'échappement dans le fichier. emit_diff se protège en forçant no_colors() dès que output.is_some(), indépendamment de l'état TTY de stdout. La palette est ensuite passée explicitement en paramètre à write_diff_text pour que le choix du writer et la décision de couleur restent synchronisés.
PgStat : analyse de hotspots pg_stat_statements
perf-sentinel pg-stat --input FILE parse les exports pg_stat_statements de PostgreSQL (CSV ou JSON, auto-détecté) et produit des classements de hotspots par temps d'exécution total, nombre d'appels et temps d'exécution moyen. Le flag --traces permet la référence croisée avec les findings de traces : l'outil exécute pipeline::analyze() sur le fichier de traces et marque les entrées pg_stat_statements dont le template normalisé apparaît aussi dans les findings.
Cette sous-commande est séparée d'analyze car les données pg_stat_statements, sans trace_id, ne peuvent pas participer au pipeline de corrélation de traces. C'est un mode d'analyse complémentaire.
Inspect : TUI interactif
perf-sentinel inspect --input FILE lance une interface terminal construite avec ratatui et crossterm. Ces dépendances sont dans sentinel-cli/Cargo.toml uniquement (pas sentinel-core) car le TUI est une préoccupation de présentation.
Disposition : découpage en 3 panneaux, liste des traces (haut-gauche, 30%), findings de la trace sélectionnée (haut-droite, 70%), détail du finding avec arbre de spans (bas, 50%). Le panneau de détail réutilise explain::build_tree() et explain::format_tree_text() pour l'affichage de l'arbre de spans.
Gestion d'état : la struct App contient des findings_by_trace pré-calculés (indexés à la construction) pour éviter de recalculer à chaque frame. L'état de navigation (selected_trace, selected_finding, active_panel, scroll_offset) est mis à jour par les événements clavier.
Chargement des données : les événements sont ingérés une seule fois, puis clonés. Une copie pour correlate() (nécessaire à la construction de l'arbre) et une pour pipeline::analyze() (consommée par le pipeline). Cela évite de relire le fichier.
Sous-commande report
perf-sentinel report --input FILE --output report.html produit un dashboard HTML en un seul fichier destiné aux devs qui explorent un artefact CI en navigateur. Le pipeline est identique à analyze de bout en bout, seul le générateur final diffère. Implémenté dans crates/sentinel-core/src/report/html/mod.rs avec le template UI complet embarqué via include_str! depuis crates/sentinel-core/src/report/html/html_template.html.
Architecture : un seul fichier, vanilla JS, pas de build step, aucune dépendance externe. La sortie est un unique fichier HTML avec tous les CSS et JS inlinés. Pas de <link rel="stylesheet">, pas de <script src="...">, pas de web fonts, pas d'images. Le fichier s'ouvre hors ligne depuis une URL file:// avec zéro requête réseau, ce qui le rend :
- Trivialement auditable : un fichier, pas de bundle minifié, pas de transpilation.
- Durable : aucune toolchain de build susceptible de casser lors de la mise à jour d'un runner CI, pas de dérive de version NPM sur une recette censée être reproductible pendant des années.
- Sûr à publier comme artefact CI : pas de lockfile à invalider, pas de vecteur supply-chain via un minifieur embarqué.
- Rapide à relire en PR : le template est un unique fichier
.htmldont le diff reste propre.
Le front-end utilise les APIs DOM directement (document.createElement, Element.textContent, Element.setAttribute), sans framework. Les Web Components ont été envisagés, mais les modules JS simples conviennent mieux au périmètre du dashboard et gardent le fichier ~15 Ko plus petit.
Modèle de sécurité : textContent seulement, vérification par grep en CI. Toutes les données contrôlées par l'utilisateur (templates SQL, URLs, noms de service, trace IDs, localisations de code, texte SuggestedFix) sont injectées dans un bloc <script id="report-data" type="application/json"> et lues une seule fois au démarrage via textContent + JSON.parse. Le JS rend ensuite via textContent et createElement exclusivement. Interdits : innerHTML, insertAdjacentHTML, document.write, eval, new Function. Un test unitaire (no_forbidden_apis_in_template dans report/html/tests.rs) grep le template à chaque build et fait échouer la CI si une de ces chaînes apparaît. Défense de second niveau : l'injecteur Rust échappe la sous-chaîne </ en <\/ dans le JSON sérialisé pour qu'une valeur hostile contrôlée par l'utilisateur ne puisse pas fermer le bloc script prématurément. \/ est un échappement JSON autorisé, donc JSON.parse récupère la valeur originale sans altération.
Seul reference_url du SuggestedFix devient un lien, et uniquement quand la valeur commence par https:// (validé côté client dans safeHttpsHref). Les URLs non-HTTPS s'affichent en texte brut sans lien.
Limitation de périmètre : post-mortem uniquement. Le dashboard est un rendu statique d'un jeu de traces terminé. Pas de polling, pas de WebSocket, pas de Server-Sent Events, pas de boucle de rafraîchissement. L'équivalent "live" depuis un daemon qui tourne reste perf-sentinel query inspect (TUI alimenté par les endpoints /api/* du daemon). Rendre le dashboard HTML live-capable nécessiterait un binding backend temps réel, une stratégie de diffing des mises à jour et une persistance d'état entre rechargements. Ce serait une architecture différente, hors périmètre ici, à reconsidérer uniquement sur retour utilisateur.
Pattern de composition pour Tempo. L'exploration adossée à Tempo se compose via le shell plutôt qu'avec un flag --tempo intégré à report : perf-sentinel tempo --endpoint ... --search ... --output traces.json && perf-sentinel report --input traces.json --output report.html. Ça évite de dupliquer ~8 flags Tempo (endpoint, search tags, fenêtre temporelle, auth, timeout, max-results, etc.) sur report et garde les deux sous-commandes chacune responsable d'une seule préoccupation (ingestion vs. rendu). Le même pattern s'applique à toute autre source d'ingestion.
Embedding des traces et cap de taille. Seules les traces référencées par un finding sont embarquées (l'onglet Explain s'amorce depuis Findings, et des traces en navigation libre ne feraient donc qu'alourdir le fichier). Une seule règle de sélection s'applique à tous les chemins de rendu, traces brutes comme Report JSON pré-calculé. Chaque trace candidate est classée par le premier finding qui la référence, les arbres gardés sont donc ceux que les lignes de tête désignent, quel que soit l'ordre choisi par l'appelant. report trie par impact quand --sort est absent, et transmet la clé choisie au dashboard via initial_sort dans le payload, la page s'ouvre donc sur l'ordre dont les arbres de tête ont été embarqués. Quand --max-traces-embedded n'est pas fixé, le générateur vise une sortie HTML d'environ 5 Mo et garde le préfixe classé qui tient. Un champ trimmed_traces: { kept, total } dans le payload embarqué alimente un bandeau dans l'onglet Findings quand la coupe se déclenche. Fixer --max-traces-embedded honore le cap exactement, en remplaçant l'heuristique 5 Mo.
Allègement du report embarqué. L'objet report sérialisé dans le payload est une copie allégée, pas le Report complet. Trois sections que le dashboard ne rend pas intégralement sont bornées pour qu'un run à forte cardinalité ne gonfle pas le fichier autonome, tandis que analyze --format json conserve chacune en entier. Quand ils dépassent 70 % du budget de taille, les findings sont coupés en gardant les critiques en premier (signalé par trimmed_findings: { kept, total }). per_endpoint_io_ops est supprimé entièrement (aucune vue du dashboard ne le lit, les deltas d'endpoint de l'onglet Diff viennent de la baseline --before, pas de ce champ). green_summary.top_offenders est plafonné à 25 lignes (le dashboard n'affiche que l'entrée de tête). L'ensemble des traces candidates vient des findings embarqués (éventuellement coupés) : chaque trace embarquée garde son finding visible dans le dashboard, au prix d'un trimmed_traces.total conservateur quand la coupe des findings se déclenche.
Sémantiques de code de sortie différentes de analyze --ci. report sort 0 même quand la quality gate échoue. Le statut de la gate est remonté via un badge rouge/vert dans la barre supérieure du HTML. Les utilisateurs qui ont besoin d'un signal de sortie pour la CI continuent d'utiliser analyze --ci.
Cross-références optionnelles : pg_stat, diff, correlations. Trois onglets optionnels sont ajoutés par des flags dédiés :
--pg-stat <FILE>ingère un exportpg_stat_statementsCSV ou JSON via le même cheminparse_pg_stat+rank_pg_statque la sous-commandepg-stat. Un onglet pg_stat affiche alors le classement par temps total (Template, Calls, Total ms, Mean ms). Les deux autres classements (par calls, par mean) restent accessibles via la sous-commande textepg-statet ne sont pas dupliqués dans le HTML.--pg-stat-prometheus <URL>interroge ponctuellement un endpointpostgres_exporterviafetch_from_prometheus, même effet que--pg-statsans fichier intermédiaire. Mutuellement exclusif avec--pg-statau niveau clap (conflicts_with). C'est un flag dereportplutôt qu'une sous-commande séparée car un GET HTTP ponctuel n'est pas une source streaming qui mérite sa propre surface de commande. Cela correspond au reste du CLI, où une source qui ne fait pas de streaming se compose avec d'autres commandes. Le scrape suppose la requête intégrée depostgres_exporter(pg_stat_statements_seconds_total, labelquery). Un exporter qui exécute du SQL écrit à la main nomme ses propres colonnes, donc--pg-stat-metric <SERIES>et--pg-stat-query-label <LABEL>le pointent vers ces noms. La sous-commandepg-statporte la même paire sous--metricet--query-label. La série arrive non encodée dans la query string, elle est donc validée contre la grammaire d'un nom de métrique PromQL nu plutôt qu'échappée : un sélecteur ou une expression à cet endroit est une erreur, pas un raccourci.--mysql-stat-prometheus <URL>est la même forme face àmysqld_exporter, avec--mysql-stat-auth-header,--mysql-stat-metricet--mysql-stat-query-labelà côté, et la sous-commandemysql-statportant la paire sous--metricet--query-label. Les défauts sontmysql_perf_schema_events_statements_seconds_totalavec un labeldigest_text, repli surdigest. Le collecteur correspondant est désactivé par défaut sur l'exporteur (--collect.perf_schema.eventsstatements). La validation d'endpoint, celle du nom de série et le transport de la requête instantanée sont partagés avec le chemin PostgreSQL dansingest::prometheus_scrape, pour que les deux chemins ne puissent pas diverger sur la validation des entrées. La correspondance des labels n'est pas partagée : c'est la seule partie qui diffère entre les deux exporteurs.--before <FILE>désérialise un rapport baseline JSON (la sortie deanalyze --format json), le passe àdiff::diff_runscontre le run courant et embarque leDiffReport. Un onglet Diff rend ensuite quatre sections : nouveaux findings (cliquables, ouvrent Explain), findings résolus (non cliquables, leurs traces sont dans la baseline qui n'est pas embarquée), changements de sévérité et deltas de métriques par endpoint (tous deux des données tabulaires non cliquables).
Onglet Correlations. Seuls les rapports produits par le daemon portent des correlations. Le pipeline batch n'en émet pas, donc l'onglet reste caché sur les sorties batch. Le JS garde sur report.correlations?.length > 0, donc l'onglet s'active automatiquement quand un futur JSON daemon est passé à perf-sentinel report --input <daemon.json>. Aucun nouveau champ n'a été ajouté au struct Report.
Navigation croisée. Deux cross-navs relient les onglets :
- Explain vers pg_stat : quand la trace d'un finding actif contient un span SQL dont le template normalisé correspond à une ligne de pg_stat, ce span reçoit la classe
ps-span-pgstat-linket un gestionnaire de clic. Le clic bascule sur l'onglet pg_stat avec la ligne correspondante surlignée et un bandeau "Filtered from Explain" affiché au-dessus de la table. Le bandeau a un lien "clear" qui le masque et retire le surlignage. Le span n'est pas cliquable quand pg_stat est absent du payload. - Diff vers Explain : les lignes de la section
new_findingssont cliquables et délèguent à la fonctionopenExplainexistante. Les lignes deresolved_findings,severity_changesetendpoint_metric_deltasne sont pas cliquables. Pour un nouveau finding dont latrace_ida été coupée, le panneau Explain affiche l'avis de coupe, qui indique les chiffres gardés/total et pointe la trace id pour une relance ciblée, plutôt qu'un arbre vide.
Recherche via /. Chacun de Findings, pg_stat, Diff, Correlations porte un <input type="search"> masqué en haut du panneau. Le gestionnaire clavier global capture / quand aucun champ n'a le focus et que l'onglet actif accepte la recherche, révèle le champ et lui donne le focus. esc quand le champ a le focus efface le filtre et masque le champ. La logique de filtrage parcourt les lignes du panneau actif et bascule display: none selon une correspondance de sous-chaîne insensible à la casse sur textContent. L'état est effacé au changement d'onglet (rien n'est reporté d'un onglet à l'autre). Explain et GreenOps n'ont pas de recherche (pas de liste de lignes significative). Le plafond de 500 lignes sur Findings s'applique toujours.
Round-trip baseline JSON. --before nécessite que le struct Report dérive Deserialize. La cascade ajoute Deserialize à Report, Analysis, GreenSummary, QualityGate, QualityRule, TopOffender, CarbonReport, CarbonEstimate, RegionBreakdown et IntensitySource. Les champs optionnels avec skip_serializing_if reçoivent un #[serde(default)] correspondant pour que le round-trip parse proprement même quand le JSON source les omet. Deux champs &'static str sur CarbonEstimate (model, methodology) et un sur RegionBreakdown (status) deviennent String pour le round-trip serde. Le coût est une poignée d'appels .to_string() sur des constantes de construction, invisible devant le travail numérique environnant.
Ergonomie strictement côté client. L'export CSV, le deep-link hash, la persistance limitée à la session et la modale cheatsheet ? sont des ajouts côté client dans html_template.html, sans changement Rust côté générateur, sans nouvel endpoint ni nouvelle dépendance.
- Export CSV : chaque onglet listable (Findings, pg_stat, Diff, Correlations) porte un bouton Export CSV au-dessus de la liste/table. Le gestionnaire de clic exécute le même prédicat de filtre que celui qui rend le DOM, assemble des lignes RFC 4180 échappées par concaténation de chaînes pure (aucun risque
innerHTML), puis déclenche le téléchargement viaBlob+URL.createObjectURL+ un<a download>temporaire. L'object URL est révoqué sur unsetTimeoutà 0ms pour éviter une fuite mémoire tout en laissant le navigateur finir le téléchargement. Explain (pas une liste) et GreenOps (résumé unique, table régions suffisamment courte pour être lue sur place) n'ont pas de bouton d'export. - Deep-link hash : le fragment d'URL encode
tab[&search=...][&ranking=...][&severity=...][&service=...]à chaque changement d'onglet, clic sur puce et changement d'input de recherche. Les écritures passent parhistory.replaceStatepour ne pas polluer l'historique back/forward. Repli pour les vieux navigateurs : assignation directe delocation.hash(un push d'historique, acceptable). Les lectures surDOMContentLoadedvalident que l'onglet est enregistré. Une cible inconnue ou un hash malformé retombe silencieusement sur les valeurs par défaut. - Persistance sessionStorage : deux clés,
perf-sentinel:theme(auto/light/dark, "auto" suit la préférence OS et s'affiche "System" dans le bouton, lue avant le premier rendu pour éviter le flash de thème) etperf-sentinel:pgstat-ranking(slug du dernier classement actif). Chaque accès est enveloppé dans untry/catchparce que le mode privé Safari et certaines politiques d'entreprise lèvent une exception sursessionStorage.setItem.localStoragen'est pas utilisé : enfile://l'originenullest partagée entre tous les fichiers HTML locaux, donc localStorage entrerait en collision entre rapports sans rapport entre eux, alors que sessionStorage est limité à l'onglet et sans collision. Le hash prime sur sessionStorage quand les deux portent une valeur. - Modal cheatsheet : un élément natif
<dialog>déclenché par?(ouvert viashowModal(), qui applique implicitement le rôle WAI-ARIA dialog et piège le focus) liste tous les raccourcis. La touche?est ignorée quand un input texte a le focus, pour que taper?dans le filtre marche toujours. Les raccourcis style vim préfixésg(g f/g e/g p/g d/g c/g r) changent d'onglet avec un timeout de 1000ms sur legen attente, les onglets masqués sont un no-op silencieux.Escgagne deux niveaux de priorité supplémentaires par-dessus l'échelle existante : fermer la cheatsheet (plus haute priorité) et effacer les puces de filtre actives (plus basse priorité). La pagination Findings remplace le plafond strict de 500 lignes par un boutonShow N more findingsqui révèle 500 lignes supplémentaires à la fois.
Invariant STATIC_CSP à la compilation
La Content-Security-Policy en mode statique est la même chaîne que celle livrée avant l'ajout du mode live. Elle interdit toute sortie réseau et tout vecteur d'exécution inline sauf les blocs inline <script> et <style> dont le rapport dépend.
Le pipeline de substitution de placeholders dans inject réécrit trois tokens ({{REPORT_JSON}}, {{PAGE_TITLE}}, {{CONTENT_SECURITY_POLICY}}) dans un ordre fixe. Toute séquence d'octets {{ qui atterrirait dans STATIC_CSP masquerait ce pipeline et corromprait silencieusement la substitution.
Un bloc const _: () = { ... while ... assert!(...) } à la compilation vérifie que STATIC_CSP.as_bytes() ne contient jamais {{. Le debug_assert! runtime dans inject couvre la moitié daemon-URL (validée par validate_url). Le bloc const couvre la moitié statique, pour qu'une modification future qui introduirait une accolade de template casse le build au lieu de corrompre silencieusement la sortie. const _: () = ... est le pattern canonique pour une vérification anonyme à la compilation qui ne déclenche pas d'avertissement dead_code.
Feature flags
Le workspace utilise des feature flags Cargo pour garder les dépendances daemon optionnelles :
| Feature | Crate | Ce qu'il active |
|---|---|---|
daemon | sentinel-core | Le stack HTTP hyper + rustls (hyper, hyper-util, http-body-util, bytes, hyper-rustls, tokio-rustls, tokio-stream, tower-http) plus arc-swap, dirs, subtle, libc. Active l'arbre de modules daemon/, scraper/state Scaphandre, scraper/state cloud energy. |
tempo | sentinel-core | hyper, hyper-util, http-body-util, bytes, hyper-rustls. Active l'ingestion via l'API HTTP Tempo. |
jaeger-query | sentinel-core | Même jeu de dépendances HTTP que tempo. Active l'ingestion via l'API de requête Jaeger (Jaeger, Victoria Traces). |
daemon | sentinel-cli | Transmet à sentinel-core/daemon, plus hyper, bytes, humantime, rpassword. Active les sous-commandes watch, query et ack. |
tui | sentinel-cli | ratatui, crossterm. Active la sous-commande inspect et le flag --tui sur analyze/explain/demo. |
tempo | sentinel-cli | Transmet à sentinel-core/tempo. Active la sous-commande tempo. |
jaeger-query | sentinel-cli | Transmet à sentinel-core/jaeger-query. Active la sous-commande jaeger-query. |
Le default du CLI est ["tui", "daemon", "tempo", "jaeger-query"]. Les utilisateurs de sentinel-core en tant que dépendance de bibliothèque peuvent l'utiliser sans daemon pour éviter le stack hyper :
perf-sentinel-core = { version = "0.8", default-features = false }Cela compile le pipeline batch complet (normalize, correlate, detect, score, report) sans code client HTTP. Les types de config (ScaphandreConfig, CloudEnergyConfig) sont toujours disponibles pour que le parseur TOML fonctionne quelles que soient les features activées. Seuls les scrapers runtime et les types state sont conditionnels.
Localisation du code source dans les findings
Structure CodeLocation
Quand les spans OTel portent des attributs de code source, les noms stables code.function.name, code.file.path et code.line.number ou les noms legacy code.function, code.filepath, code.lineno et code.namespace, ils sont extraits pendant l'ingestion et stockés sur SpanEvent sous forme de quatre champs optionnels. Le pipeline de détection les propage vers Finding.code_location: Option<CodeLocation> :
pub struct CodeLocation {
pub function: Option<String>,
pub filepath: Option<String>,
pub lineno: Option<u32>,
pub namespace: Option<String>,
}Les quatre champs sont optionnels et présents indépendamment les uns des autres. Les noms stables l'emportent sur les noms legacy, et un code.function.name qualifié donne aussi le namespace. En OTLP, un span sans attribut de code prend la localisation de son plus proche ancêtre qui en a une, dans le même service, jusqu'à ANCESTOR_WALK_MAX_DEPTH niveaux. La plupart des agents OTel auto-instrumentés émettent un nom de fonction mais ni chemin de fichier ni numéro de ligne, et le starter Spring Boot n'en émet aucun par défaut. Le système se dégrade proprement : les findings sans attributs source apparaissent sans ligne source, sans bruit dans la sortie. Les findings structurels (serialized_calls, excessive_fanout, chatty_service, pool_saturation) portent la localisation d'un appel représentatif, voir la section sur le détecteur de framework de 04 · Détection.
Affichage CLI
Quand code_location est présent, le CLI affiche une ligne "Source:" sous l'endpoint du finding :
Source: com.example.OrderService.processItems (OrderService.java:42)Le format est namespace.function (filepath:lineno), chaque partie étant omise si elle est absente. La logique de rendu construit la chaîne par étapes : namespace et function sont joints par un point, ou par :: quand le namespace contient \ ou :: (PHP, Rust, C++, Ruby), la fonction s'affiche seule quand elle commence déjà par le namespace suivi d'un séparateur, comme le code.function.name stable, et filepath et lineno sont ajoutés entre parenthèses seulement quand la partie nom est aussi présente.
Enrichissement SARIF physicalLocation
Chaque résultat SARIF porte le service et l'endpoint dans properties.service et properties.endpoint. Quand le finding porte une CodeLocation avec au moins un filepath, le résultat reçoit aussi un tableau locations avec exactement une location, qui a une physicalLocation et des logicalLocations reprenant le service (kind: "module") et l'endpoint (kind: "function") :
{
"physicalLocation": {
"artifactLocation": { "uri": "OrderService.java" },
"region": { "startLine": 42 }
},
"logicalLocations": [
{ "name": "order-svc", "kind": "module" },
{ "name": "POST /api/orders/{id}/submit", "kind": "function" }
]
}Le champ region.startLine n'est inclus que si lineno est disponible. Cela permet les annotations en ligne dans GitHub Code Scanning et GitLab SAST quand le rapport SARIF est envoyé comme résultat de code scanning.
Assainissement de code.filepath
L'attribut OTel code.filepath est contrôlé par l'attaquant (un span hostile peut y mettre n'importe quelle chaîne). Avant de l'émettre comme artifactLocation.uri SARIF, sanitize_sarif_filepath rejette toute valeur qui pourrait hameçonner un lecteur ou contourner les résolveurs de code scanning. Le sanitizer abandonne entièrement l'URI (renvoie None) dans chacun de ces cas :
- Chemins absolus (POSIX
/..., Windows\...). - Tout deux-points. Les chemins sources légitimes dans les apps instrumentées ne contiennent pas de deux-points. Rejet inconditionnel pour éviter les contournements subtils autour de
javascript:,data:,file:, etc. - Segments de path traversal. Le littéral
..comme les variantes percent-encodées (%2e%2e,%2E%2E, casse mixte,.%2e,%2e.) sont interceptés. - Séquences double-encodées (
%25...) qui décodent en%au premier passage puis en caractère réel au second. - Préfixes UTF-8 overlong (
%c0,%c1) qui décodent en encodages non-canoniques de caractères ASCII dans les décodeurs laxistes. - Caractères de contrôle (retours à la ligne, NUL, etc.) qui pourraient casser le tokenizer du consommateur SARIF ou injecter dans les logs.
- Surcharges Unicode BiDi et caractères de format invisibles (
U+061C,U+180E,U+202A..U+202E,U+2066..U+2069,U+200B..U+200F,U+FEFF) qui peuvent confondre l'affichage des noms de fichier (classe d'attaque Trojan Source, CVE-2021-42574).
Les findings dont le filepath est rejeté apparaissent toujours dans le rapport SARIF. Ils n'ont pas de locations, les autres champs du résultat restent. Une location sans physicalLocation serait du SARIF valide, mais le parseur de Warnings NG (Jenkins, violations-lib) rejette alors tout le fichier.
Sous-commande query
perf-sentinel query interroge l'API HTTP d'un daemon en cours d'exécution. Elle nécessite le feature flag daemon.
Sous-actions
| Sous-action | Endpoint API | Sortie | Description |
|---|---|---|---|
findings | /api/findings | terminal coloré (défaut) ou JSON | Lister les findings récents avec filtres --service, --finding-type, --severity, --limit |
explain | /api/explain/{trace_id} | arbre coloré (défaut) ou JSON | Afficher l'arbre explain d'une trace depuis la mémoire du daemon |
inspect | /api/findings | TUI ratatui | TUI interactif 3 panneaux alimenté par les données live du daemon |
correlations | /api/correlations | tableau coloré (défaut) ou JSON | Afficher les corrélations cross-trace actives |
status | /api/status | résumé coloré (défaut) ou JSON | Afficher l'état du daemon : version, uptime, traces actives, nombre de findings stockés |
Toutes les sous-actions sauf inspect acceptent --format text|json. Le défaut est text (sortie colorée dans le terminal), comme le défaut de la commande analyze. --format json produit du JSON brut pour le scripting et l'automatisation.
Sortie colorée
findings réutilise la fonction existante print_findings() de la commande analyze, donc la sortie colorée est identique : étiquettes colorées selon la sévérité, localisation du code source, template, suggestion, impact environnemental.
explain désérialise la réponse JSON du daemon en ExplainTree et appelle format_tree_text() pour l'arbre de spans coloré avec findings en ligne, identique à perf-sentinel explain.
inspect récupère tous les findings via /api/findings?limit=10000, puis, pour chaque trace_id distinct, récupère l'arbre explain via /api/explain/{trace_id} et le désérialise en ExplainTree. Les arbres colorés pré-rendus sont passés au TUI via App::with_pre_rendered_trees, donc le panneau de détail affiche de vrais arbres de spans (pas des coquilles vides) pour chaque trace encore présente dans la TraceWindow du daemon. Les traces évincées renvoient le panneau de détail sans arbre (omission silencieuse, pas de panneau vide déroutant).
correlations affiche un tableau coloré dédié, avec le pourcentage de confiance coloré selon sa valeur (rouge >= 80%, jaune >= 50%).
status affiche des paires clé-valeur avec la version, l'uptime formaté (Xh Ym Zs), les traces actives et le nombre de findings stockés.
Implémentation
La fonction cmd_query construit une closure autour de http_client::fetch_get qui gère les échecs de connexion avec un message d'erreur explicite ("Is perf-sentinel watch running?"). Chaque sous-action construit le chemin d'URL approprié, récupère la réponse et l'affiche selon le flag --format.
L'URL par défaut du daemon est http://localhost:4318, qui correspond au port d'écoute HTTP par défaut du daemon. Les utilisateurs peuvent la remplacer avec --daemon http://host:port.
Parsing de la configuration
Format sectionné (seul format accepté à partir de 0.6.0)
La config exige une forme sectionnée : chaque tunable vit sous [thresholds], [detection], [green] ou [daemon].
[detection]
n_plus_one_min_occurrences = 5serde(default) produit None pour les champs absents. La conversion From<RawConfig> for Config est un .unwrap_or(default) plat par champ :
n_plus_one_threshold: raw.detection.n_plus_one_min_occurrences
.unwrap_or(defaults.n_plus_one_threshold),Breaking change 0.6.0 : 8 clés top-level legacy retirées
load_from_str exécute reject_legacy_top_level_keys avant le parse typé RawConfig. Huit clés top-level 0.5.x (n_plus_one_threshold, window_duration_ms, listen_addr, listen_port, max_active_traces, trace_ttl_ms, max_events_per_trace, max_payload_size) produisent désormais une ConfigError::Validation dont le message nomme à la fois la clé retirée et son remplacement sectionné, donc cargo run --bin perf-sentinel watch sur une config 0.5.x échoue rapidement et indique à l'opérateur exactement quoi modifier. La table de migration complète est dans Configuration.
Bornes de validation
Chaque champ numérique a des bornes explicites dans validate() :
| Champ | Min | Max | Raison |
|---|---|---|---|
max_payload_size | 1 024 | 104 857 600 (100 Mo) | Empêcher la désactivation de la protection des entrées |
max_active_traces | 1 | 1 000 000 | Empêcher la mémoire non bornée |
max_events_per_trace | 1 | 100 000 | Empêcher l'OOM par trace |
max_retained_findings | 0 | 10 000 000 | Empêcher l'OOM sur le store de findings. 0 est documenté comme "désactiver complètement le store" |
n_plus_one_threshold | 1 | (aucun) | Au moins 1 occurrence pour détecter |
window_duration_ms | 1 | (aucun) | Fenêtre non nulle |
slow_query_threshold_ms | 1 | (aucun) | Seuil non nul |
slow_query_min_occurrences | 1 | (aucun) | Au moins 1 occurrence |
slow_query_window_minutes | 0 | 60 | 0 désactive la fenêtre lente inter-batchs |
max_fanout | 1 | 100 000 | Empêcher la désactivation de la détection |
trace_ttl_ms | 100 | 3 600 000 (1 h) | Intervalle d'éviction minimum |
sampling_rate | 0.0 | 1.0 | Probabilité valide |
ingest_queue_capacity | 1 | 1 048 576 | Plafond de backpressure de la file bornée |
analysis_queue_capacity | 1 | 1 048 576 | Plafond de backpressure de la file bornée |
listen_port_http | 1 | 65 535 | Port TCP valide |
listen_port_grpc | 1 | 65 535 | Port TCP valide |
chatty_service_min_calls | 1 | (aucun) | Au moins 1 appel pour détecter |
pool_saturation_concurrent_threshold | 2 | (aucun) | Au moins 2 requêtes concurrentes pour saturer |
serialized_min_sequential | 2 | (aucun) | Au moins 2 appels pour former une séquence |
io_waste_ratio_max | 0.0 | 1.0 | Ratio valide |
La vérification de listen_addr non-loopback émet un avertissement mais ne rejette pas :
tracing::warn!(
"Daemon configured to listen on non-loopback address: {}. \
OTLP ingest, /metrics and most read endpoints are never \
authenticated. Put a reverse proxy or a network policy in front.",
self.listen_addr
);Cela permet aux utilisateurs avancés de lier à 0.0.0.0 derrière un reverse proxy, tout en rendant explicites les implications de sécurité.
Normalisation des chemins Windows
.perf-sentinel.toml accepte des champs à valeur de chemin (hourly_profiles_file, calibration_file, json_socket, tls_cert_path, tls_key_path) écrits comme basic strings TOML, où \ est normalement un caractère d'échappement. Un chemin Windows littéral comme C:\temp\sock écrit dans une basic string déclenche une erreur de parsing TOML car \t est interprété comme une tabulation.
Pour faire fonctionner les configs Windows sans forcer les opérateurs à doubler les backslashes (C:\\temp\\sock), load_from_str exécute un pré-processeur étroit avant le parsing TOML :
normalize_toml_path_stringsparcourt l'entrée brute ligne par ligne. Pour les lignes dont la clé est dansTOML_PATH_STRING_KEYSet dont la valeur est une basic string ("..."), il réécrit la valeur viaescape_toml_path_backslashes.escape_toml_path_backslashesparcourt la chaîne par runs de\consécutifs :- run de 1 : émettre
\\(un\isolé devient une paire d'échappement TOML). - run de 2 ou plus : émettre tel quel (paire d'échappement déjà valide ou
\\\\écrit volontairement). - run de 2 au tout début de la valeur, non suivi d'un autre
\: émettre\\\\(4 backslashes) pour qu'un UNC brut\\server\sharese décode en\\server\share. find_basic_string_endlocalise le"fermant de la basic string avec un compteur linéaire de backslashes consécutifs (le nombre de\immédiatement avant le"doit être pair). Un lookbehind serait O(n²) sur des entrées adverses pleines de\.- Repli : si l'entrée normalisée échoue à parser mais que l'originale aurait fonctionné,
load_from_strretente avec l'originale et émet une lignetracing::debug!pour que la divergence reste diagnostiquable sans bruit sur chaque config Windows légitime.
Non touchés par cette normalisation : les literal strings TOML ('C:\temp\sock', qui traitent déjà \ littéralement) et toute clé absente de TOML_PATH_STRING_KEYS. Effet de bord : les séquences d'échappement TOML (\t, \n, \u00XX) à l'intérieur des clés ciblées sont traitées comme des paires d'octets littéraux plutôt que des échappements. Ce comportement convient à des chemins de fichiers et il est documenté dans le rustdoc du helper.
Petit invariant UTF-8 : normalize_toml_path_line construit la ligne réécrite en découpant sur [..value_start] (exclusif) et en poussant le " ouvrant explicitement, donc value_start n'est jamais utilisé comme fin d'une plage d'octets inclusive. L'octet à value_start est ASCII " en pratique, mais la forme explicite verrouille l'invariant pour les futurs lecteurs.
Avertissements de zone de confort
Au-delà des bornes dures de validation, validate_daemon_limits et validate_detection_params émettent un tracing::warn! unique au chargement de la config quand une valeur sort d'une "zone de confort" recommandée autour du défaut. L'avertissement est informatif : le daemon continue de tourner.
Les zones de confort encadrent chaque défaut sur environ 1 à 2 ordres de grandeur. Elles ont été choisies à partir des défauts déjà présents dans Config::default() :
| Champ | Zone de confort | Note |
|---|---|---|
max_payload_size | 256 Kio à 16 Mio | |
max_active_traces | 1 000 à 100 000 | |
max_events_per_trace | 100 à 10 000 | |
max_retained_findings | 100 à 100 000 | Sauté silencieusement quand la valeur vaut 0 |
trace_ttl_ms | 1 000 à 600 000 | |
max_fanout | 5 à 1 000 |
La fonction utilitaire warn_outside_comfort_zone prend le nom du champ, la valeur, les deux bornes et deux notes courtes (une "sous le plancher", une "au-dessus du plafond") décrivant la conséquence pratique (pression d'éviction, latence d'ingestion, bruit de détection...). La fonction journalise des champs structurés (field, value, recommended_min ou recommended_max) pour que l'avertissement soit interrogeable dans Loki / Elasticsearch.
Invariant verrouillé par config_defaults_sit_inside_every_comfort_zone : Config::default() ne doit jamais déclencher d'avertissement au démarrage. Si un défaut est déplacé hors de sa zone de confort, ce test échoue et force une vérification explicite de la bande.
Le résumé utilisateur des bandes vit dans Configuration à côté des tableaux des champs.
Profil release
[profile.release]
codegen-units = 1
lto = "thin"
strip = true
panic = "abort"
opt-level = 3codegen-units = 1
Une seule unité de codegen active l'optimisation du crate entier : le compilateur peut inliner à travers tous les modules et optimiser le crate entier comme une seule unité de traduction. Le compromis est un temps de compilation plus long (le codegen parallèle est désactivé). Pour les builds release, c'est acceptable.
Référence : The Rust Performance Book: Build Configuration
lto = "thin"
ThinLTO fournit la plupart des bénéfices de taille de binaire et de performance du LTO complet avec des temps de liaison significativement plus rapides. Le LTO complet ajoute ~30s au temps de liaison sur ce projet avec un bénéfice supplémentaire marginal. ThinLTO permet l'inlining inter-modules et l'élimination de code mort tout en prenant en charge les builds incrémentaux.
strip = true
Supprime les symboles de debug du binaire release. Réduit la taille de ~15 Mo à ~8 Mo. Acceptable pour un outil CLI distribué où les utilisateurs n'ont pas besoin d'informations de debug.
panic = "abort"
Élimine la machinerie d'unwinding (~200 Ko d'économie binaire). Puisque Perf Sentinel est un outil autonome (pas une bibliothèque consommée par du code Rust qui attrape les panics avec catch_unwind), abort-on-panic est sûr et réduit à la fois la taille du binaire et le surcoût à l'exécution.
opt-level = 3
Optimisation maximale : inlining agressif, vectorisation de boucles et élimination de code mort. Le chemin chaud de Perf Sentinel est le traitement de données (correspondance de chaînes, opérations HashMap, chaînes d'itérateurs) qui bénéficie de l'inlining. La documentation Cargo note que la différence entre opt-level = 2 et 3 est principalement un inlining plus agressif, ce dont un outil pipeline a besoin.
L'alternative opt-level = "s" (optimiser pour la taille) a été envisagée mais rejetée : la différence de taille binaire est marginale (~200 Ko), tandis que la différence de débit peut atteindre 10-30% sur les charges de traitement de données.
Pas de overflow-checks en release
overflow-checks reste à son défaut release (désactivé). Les profils dev et test le gardent activé via le défaut standard debug_assertions, donc un débordement entier se manifeste comme un panic en CI avant qu'une release ne soit produite, là où c'est un bug à corriger plutôt qu'une valeur à laquelle se fier.
L'activer en release serait globalement défavorable pour ce binaire. Il ne concerne que l'arithmétique entière, et combiné avec panic = "abort" ci-dessus, chaque débordement vérifié devient un abort immédiat du process. Sur un daemon watch longue durée qui ingère du trafic réseau non maîtrisé, cela transforme n'importe quel débordement entier sur de l'arithmétique influencée par l'attaquant (nombres de spans, tailles de payload, durées) en déni de service distant. Le reste du chemin d'ingestion est conçu pour éviter ce mode de défaillance.
Le flag ne traite pas non plus la préoccupation le plus souvent soulevée ici, un rebouclage silencieux des compteurs carbone. Ces accumulateurs sont des f64 (operational_gco2, total_transport_gco2, energy_kwh dans score/carbon_compute.rs), et les flottants ne rebouclent jamais. Ils saturent à inf et produisent NaN sur opération invalide. overflow-checks ne concerne que les entiers et n'a aucun effet sur eux. Le risque d'exactitude qui concerne bien le calcul carbone est la propagation de inf/NaN, gérée par une garde de valeur au site (par exemple la garde de diviseur calls > 0 dans ingest/pg_stat.rs), pas par un profil de build.
Là où un calcul entier précis sur entrée hostile pourrait déborder et où un résultat faux compterait, le code durcit ce site localement avec checked_*, saturating_* ou u64::try_from(...).ok() plutôt que d'armer un panic global.
Allocateur sur les builds musl
Les binaires Linux de release ciblent x86_64-unknown-linux-musl et aarch64-unknown-linux-musl pour que l'artefact soit entièrement statique et tourne sur n'importe quelle distribution quelle que soit la glibc hôte. La libc musl embarque son propre allocateur, simple et compact mais plus lent que celui de la glibc sous contention : sur v0.4.6, l'allocateur musl par défaut faisait tourner le bench à environ 70 % du débit du build glibc, le seul coût notable du choix musl vs glibc.
Plutôt que de ressusciter une matrice de release dual glibc/musl pour combler l'écart, le crate CLI déclare mimalloc comme dépendance target-gated :
[target.'cfg(target_env = "musl")'.dependencies]
mimalloc = "0.1.52"et remplace l'allocateur global dans main.rs derrière le même cfg :
#[cfg(target_env = "musl")]
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;Conséquences :
- Sur les cibles musl (artefacts Linux de release) : mimalloc remplace automatiquement l'allocateur système au moment du link. Les benchs de décision v0.4.7 le montraient combler l'écart musl et dépasser le baseline glibc d'environ 30 % (et l'allocateur Darwin de macOS d'un facteur ~2 sur le microbench), porté par sa disposition en segments/pages sur les allocations petites-à-moyennes qui dominent le chemin chaud de Perf Sentinel. Re-mesuré sur 0.6.1 et 0.8.0 avec la même conclusion. Les tables par campagne vivent dans l'historique git.
- Sur macOS, Windows et n'importe quelle future cible
*-linux-gnu: la gardecfg(target_env = "musl")vaut faux,mimallocn'est même pas compilé, l'allocateur système reste en place. Aucun changement de surface pour ces plateformes. - Coût RSS : environ +21 % sur la charge du bench. Compromis attendu pour un allocateur plus rapide qui pré-alloue ses arenas. La RSS résultante reste un ordre de grandeur sous le plafond de 200 Mo documenté pour le daemon et bien dans les plages requests/limits K8s recommandées dans les values Helm.
La forme sans feature flag, target-gated, a été retenue plutôt qu'une feature cargo opt-in pour deux raisons. D'abord, il n'y a pas de raison plausible, sur un build musl, de garder le défaut plus lent. Ensuite, le remplacement n'a aucune surface visible utilisateur, donc l'exposer en toggle alourdirait la doc sans bénéfice correspondant.
Méthodologie de mesure
- Microbench in-memory :
perf-sentinel benchchronomètrepipeline::analyze(fonction pure, sans I/O ni dispatch async). Le bon chiffre pour le suivi de régression allocateur et pipeline, pas pour le dimensionnement de capacité. - End-to-end via le daemon :
perf-sentinel watchalimenté par le chemin NDJSON[daemon] json_socket, débit mesuré côté émission, exactitude validée en sondant/api/statusjusqu'au drain deactive_tracesà 0. Ce chemin JSON-socket est lui-même une borne haute du vrai chemin d'ingestion OTLP (le décodage protobuf coûte plus que le parse JSON serde).
Pour citer le débit de Perf Sentinel à l'extérieur, préférer le chiffre end-to-end (ou les deux, avec leurs conditions). Les tables des campagnes historiques (décision allocateur v0.4.7, re-mesure 0.6.1, contrôle de toolchain 0.8.0) ont été retirées de cette page et vivent dans l'historique git. Leur conclusion commune tient : la décision d'allocateur est stable d'une release à l'autre et le chemin chaud de pipeline::analyze n'a porté aucune régression entre 0.4.7 et 0.8.0.
Campagne de mesure v0.8.7
0.8.7 remplace les fixtures ad hoc par une infrastructure de mesure permanente : un générateur synthétique avec graine (sentinel_core::synth, masqué de la doc), une suite criterion sur chaque étape du pipeline (crates/sentinel-core/benches/pipeline.rs, baselines via cargo bench -p perf-sentinel-core -- --save-baseline main), bench --synthetic-events N pour des runs sans fixture, et un profil de build profiling (release plus symboles) pour les flamegraphs. Les flamegraphs s'obtiennent avec cargo flamegraph --profile profiling -- bench --synthetic-events 1000000 sous Linux et avec samply record sous macOS.
Le corpus synthétique est plus lourd que les fixtures historiques dérivées de la démo (mix d'anti-patterns réaliste, multi-services, scoring carbone actif), ses chiffres forment donc une nouvelle série de référence plutôt qu'un prolongement des tables des campagnes historiques. Sur ce corpus, bench --synthetic-events 100000 atteint ~1,0-1,1M événements/s (macOS natif, bruit hôte inclus), et les médianes criterion du pipeline complet sont de 85 ms / 100k événements et 926 ms / 1M événements.
Optimisations livrées, chacune avec sa preuve criterion avant/après :
- Parse ISO 8601 : chemin rapide sur layout fixe plus chemin général sans allocation, 69,5 ns vers 8,4 ns par parse (−88 %), benchs détecteurs −4 % à −9 % (les horodatages sont parsés plusieurs fois par span entre la détection et le carbone).
- ServiceMeter : enfants de counter par service pré-cachés, 11,9 ns vers 1,0 ns par événement sur le chemin de métrologie (micro bench
service_counter/). - Harnais
bench: le clone passe dans la boucle d'itération, le pic RSS tombe deiterations x inputà2 x input(l'ancien ~641 Mo à 31,2k x 30 lit ~10x plus bas sur la même charge, donc annoter les comparaisons qui traversent cette frontière). - Le CLI batch libère le buffer d'entrée brut avant l'analyse (pic RSS moins la taille du fichier sur
analyze/diff).
Évaluées et non retenues, avec les mesures qui les ont fermées :
- Interning des templates dans normalize :
normalize_allreprésente 15,2 ms des 85,2 ms / 100k du pipeline (18 %). Une coupe optimiste de 30 % rapporte ~5 % de bout en bout pour un vrai coût de complexité (interner explicite à enfiler dans le pipeline pur). À rouvrir seulement si la normalisation domine un futur flamegraph. - Cache
timestamp_mssurNormalizedEvent: après le chemin rapide du parse, le coût résiduel est ~3 % du pipeline, sous la barre des 5 %. - Ingestion NDJSON en streaming pour le CLI batch : différée. Le plafond d'entrée batch de 1 GiB (découplé de la limite de payload du daemon en 0.8.7) couvre les tailles de corpus actuelles. La corrélation batch travaille sur l'ensemble complet, le streaming n'économiserait donc que la part du buffer brut dans la RSS.
snapshot_service_io_ops: collecte une seule famille deCounterVecpar tick de scraper, pas le registre entier, et reste borné par le plafond de 1024 services. Non-problème.
Stratégie de distribution
- GitHub Releases (principal) : binaires multi-plateformes pour 4 cibles (linux/amd64, linux/arm64, macOS/arm64, windows/amd64) avec checksums SHA256. Aucun binaire n'est publié pour les Mac Intel : installez Perf Sentinel avec
cargo install, ou lancez l'image Docker cargo install perf-sentinel --lockedvia crates.io- Docker (
FROM scratch,USER 65534) : image minimale pour les déploiements Kubernetes
Les GitHub Actions sont épinglées aux SHAs de commit pour la sécurité de la chaîne d'approvisionnement. L'outil cross utilisé pour la cross-compilation ARM est épinglé à une version spécifique (--version 0.2.5) pour éviter des comportements inattendus lors de mises à jour upstream. Le workflow de release génère des checksums SHA256 pour tous les binaires.
Sous-commande diff
perf-sentinel diff --before <traces-old.json> --after <traces-new.json> [--config foo.toml] [--format text|json|sarif] [--output file]
Compare deux jeux de traces et émet un rapport delta. Cas d'usage principal : intégration CI sur les PR pour faire ressortir les régressions et améliorations introduites par un changement. Le handler exécute pipeline::analyze sur chaque fichier de traces avec la même Config, puis appelle diff::diff_runs(&before_report, &after_report).
Tuple d'identité
Les findings sont appariés entre les runs via le tuple (finding_type, service, source_endpoint, pattern.template). Les templates sont normalisés au moment de la détection donc l'égalité directe suffit, pas de re-normalisation au moment du diff. Quand le même tuple d'identité apparaît plusieurs fois dans un run (par exemple un template N+1 qui déclenche sur plusieurs traces), le moteur de diff fusionne les doublons en gardant celui de pire sévérité. Cela évite de traiter une différence de comptage pour le même template comme un changement de sévérité.
Sections de sortie
Le DiffReport porte quatre listes :
new_findings: tuples d'identité présents dansaftermais absents debefore.resolved_findings: présents dansbeforemais absents deafter.severity_changes: même identité dans les deux runs, sévérité différente. Triés régressions en premier.endpoint_metric_deltas: deltas de comptage I/O par endpoint, triés pardeltadécroissant (régressions en premier). Sourcés depuisgreen_summary.per_endpoint_io_ops, que le pipeline peuple toujours indépendamment de[green] enabled.
Formats de sortie
- text (défaut) : en-tête de résumé suivi de quatre sections, code couleur (rouge pour les régressions, vert pour les améliorations). Conçu pour la revue en terminal.
- json :
DiffReportcomplet sérialisé viaserde_json::to_writer_pretty. La forme JSON stable reflète le layout des structs du module diff. - sarif : seuls les
new_findingssont émis comme résultats SARIF, puisque "resolved" et "severity changed" n'ont pas d'équivalent SARIF natif. Convient aux pipelines d'annotation de PR (GitHub Code Scanning, GitLab Code Quality) qui n'ont besoin que de faire ressortir les régressions.
Pas de flag --ci
Le quality gate analyze --ci n'est pas dupliqué sur diff : le diff lui-même est le signal. Une liste new_findings non-vide, une régression dans severity_changes ou une entrée positive dans endpoint_metric_deltas sont autant de signaux sur lesquels le consommateur CI peut décider d'échouer, selon sa politique.
Bandes d'interprétation : ce qui est ancré et ce qui est une règle empirique
report/interpret.rs transforme deux métriques brutes en une étiquette healthy | moderate | high | critical. Les étiquettes sont une aide au rendu, mais trois des seuils derrière elles ne sont pas arbitraires, et la différence compte pour qui serait tenté de les régler.
IIS_HIGH(5.0) est ancré sur len_plus_one_thresholdpar défaut du détecteur N+1. Un endpoint qui l'atteint est arithmétiquement au point oùdetect_n_plus_onecommence à émettre.IIS_CRITICAL(10.0) est mécaniquement ancré surdetect::n_plus_one::CRITICAL_OCCURRENCE_THRESHOLD, avec un test garde-fou qui casse le build si l'une des deux valeurs bouge sans l'autre.IIS_MODERATE(2.0) est une règle empirique, pas une mesure : l'intuition qu'un endpoint CRUD typique fait une ou deux opérations d'I/O par requête. Attendez-vous à y voir beaucoup d'endpoints légitimes.WASTE_RATIO_HIGH(0.30) est ancré sur l'io_waste_ratio_maxpar défaut, pas sur la valeur configurée par l'opérateur. La gate est une politique utilisateur, l'interprétation est une heuristique fixe, et un lecteur qui compare deux rapports a besoin que le second veuille dire la même chose dans les deux.
Le contrat JSON sépare la stabilité en deux. Les valeurs de l'énumération ("healthy", "moderate", "high", "critical") sont stables entre versions et les consommateurs peuvent s'y fier. Les seuils derrière elles sont versionnés avec le binaire et peuvent bouger. Un consommateur qui veut une classification indépendante de la version doit lire io_intensity_score et io_waste_ratio bruts et appliquer ses propres bandes. C'est le même schéma que co2.model, qui évolue à travers io_proxy_v1..v3 sans casser un consommateur qui veut seulement savoir quel modèle a tourné.
NaN classe en Healthy partout, puisqu'il compare faux contre tous les seuils. C'est le sens voulu : une donnée manquante ne doit pas s'afficher en rouge.
Durcir les sorties rendues
Trois générateurs placent des chaînes dans l'interface de quelqu'un d'autre, et tous trois considèrent la même classe d'attaque comme dans leur périmètre : un span hostile qui met dans service.name, http.url ou code.filepath quelque chose qui s'affiche autrement qu'il ne se lit. L'échappement JSON côté consommateur ferme l'injection mais pas l'usurpation.
SARIF (report/sarif.rs) est le plus strict, parce que le code scanning de GitHub et de GitLab le rend. Toute chaîne non fiable qui atteint un message ou une logical location se voit retirer ses surcharges BiDi et ses caractères de format invisibles (Trojan Source, CVE-2021-42574) : char::is_control ne les attrape pas, car ce sont des caractères de format. code.filepath reçoit un validateur dédié qui rejette tout deux-points plutôt que de tailler une exception pour les lettres de lecteur Windows, car l'exception rouvre les contournements javascript:, data: et A:B:C:// alors qu'un chemin source légitime dans une application instrumentée n'en contient pas. Les encodages UTF-8 surlongs de . (%c0%ae, %e0%80%ae, le bug IIS classique) sont rejetés en bloc. Un chemin qui échoue à un contrôle est abandonné plutôt qu'assaini, car un chemin à moitié réparé induit plus en erreur qu'un chemin absent. Les métadonnées d'acquittement reçoivent le même traitement à la sortie : c'est du texte libre contrôlé par l'opérateur, et alice<RLO>@evil.com usurpe une identité dans l'interface du relecteur.
Les warnings (report/warnings.rs) portent un contrat explicite plutôt qu'un mécanisme. Warning::new n'assainit rien parce que tous les producteurs actuels passent un littéral en dur ou un format! sur un compteur. C'est une propriété des sites d'appel d'aujourd'hui, pas du type, le module énonce donc la règle : un Warning construit depuis quoi que ce soit touchant à des octets contrôlés par l'utilisateur (attributs OTLP, noms de spans, en-têtes, chaînes de configuration) doit passer par Warning::from_untrusted. Le warning_details structuré coexiste avec le warnings: Vec<String> historique, les renderers préférant le premier quand il est non vide, pour que les références plus anciennes continuent de se parser via serde(default).
text_safety.rs détient les deux primitives que le reste de l'arbre appelle : sanitize_for_terminal et safe_url. Tout ce qui est influencé par un attaquant et atteint un terminal ou le dashboard HTML passe par elles plutôt que de faire pousser son propre échappement.