perf sentinelperf sentineldocs
FRENGitHub
Documentation / Limites

Limitations connues et compromis

Sommaire

Fiabilité de la capture OTLP

Perf Sentinel est un écouteur passif : il reçoit les traces transmises par les SDKs ou collecteurs OpenTelemetry et ne peut pas garantir la capture de chaque span. Des spans peuvent être perdus à cause de problèmes réseau, de l'échantillonnage du SDK ou du collecteur, ou de plantages de l'application avant le flush.

Pour les pipelines CI critiques, utilisez le mode batch (perf-sentinel analyze) sur des fichiers de traces pré-collectés plutôt que la capture en direct.

La qualité de l'instrumentation borne les findings

Chaque finding dérive d'un span normalisé. Perf Sentinel lit une liste fermée d'attributs porteurs (le texte de la requête db.statement / db.query.text, l'URL cible http.url / url.full, plus les attributs d'enrichissement listés dans Attributs de span requis). Un span qui n'en porte aucun n'est pas une opération d'I/O et est ignoré. Un span SQL qui est une opération d'I/O mais arrive sans texte de requête, ou un span HTTP sans URL, est lui aussi ignoré : il n'y a rien à normaliser, donc aucun finding ne peut être produit. Depuis 0.11.2, le kind du span écarte un cas de plus même quand l'attribut est présent. L'URL d'un span SERVER décrit la requête à laquelle le service a répondu et non un appel qu'il a émis, donc elle ne produit aucun événement HTTP sortant. Elle fournit toujours l'endpoint entrant auquel les autres findings sont rattachés. La détection est bornée par la qualité de l'instrumentation en amont, de la même manière que tout outil purement logiciel est borné par sa source de mesure.

L'écartement n'émet ni avertissement ni erreur par span, donc un attribut manquant ne remonte pas comme un problème, il remonte comme l'absence de finding. Depuis 0.8.7 le daemon compte ce filtrage en agrégé : perf_sentinel_otlp_spans_received_total et perf_sentinel_otlp_spans_filtered_total{reason} exposent le taux de rétention sur /metrics (voir Métriques). Une flotte dont tous les spans sont filtrés devient donc visible sans bruit par span. Depuis 0.10.0 le chemin batch compte aussi : analyze et report sur une entrée OTLP portent le même décompte par raison dans analysis.ingest du rapport JSON (spans reçus, filtrés par raison, et ratio de spans exploitables), et le rapport texte affiche le décompte dès qu'un span a été filtré. diff n'est pas couvert : un DiffReport ne porte que l'écart entre deux runs et n'a pas de bloc analysis. Un [thresholds] min_usable_span_ratio optionnel transforme ce ratio en cinquième règle de quality gate. Un pipeline CI peut donc échouer sur une instrumentation inexploitable au lieu de passer au vert à tort (voir Configuration). La règle ne s'applique qu'aux runs batch. Le signal équivalent côté daemon est la paire de rétention /metrics décrite plus haut. Les entrées natives, Jaeger et Zipkin ne portent pas de décompte.

Le ratio est calculé par nature d'I/O et rapporte la pire d'entre elles, pas une part globale : un service émettant 900 spans HTTP exploitables et 100 spans SQL tous privés de db.statement donnerait 0,90 en global et franchirait sans alerte un seuil à 0,9 alors que tous les détecteurs SQL sont aveugles. Une nature portant moins de 20 spans d'I/O n'est pas jugée du tout plutôt que mal jugée : un ratio sur une poignée de spans est du bruit, et celui-ci bloque des builds. Des angles morts subsistent par construction. Un span sans statement qui ne porte aucun db.system est indiscernable d'un span interne. Il compte donc comme not_io et sort entièrement du ratio. Et un db.system sans statement, c'est aussi à quoi ressemblent les spans de connexion, de transaction et de préparation : les instrumentations qui en émettent gonflent le dénominateur. Gardez donc de la marge sous 1,0 en fixant le seuil. Les appels RPC sortants (gRPC, Dubbo) sont modélisés comme du HTTP mais ne lisent aucune URL et ne peuvent donc pas en manquer une. Ils sont tenus hors du ratio HTTP plutôt que d'en gonfler le numérateur. Le décompte n'a pas non plus de dimension par service : un service sain remonte le ratio d'une flotte dont un autre service est totalement aveugle, donc sur une capture multi-services le chiffre est une moyenne de flotte, pas un verdict par service. Les récupérations tempo ne portent aucun décompte, donc la règle ne s'y déclenche jamais. La cause courante en pratique est une instrumentation qui omet le texte de la requête par défaut : plusieurs bibliothèques masquent les requêtes pour des raisons de sécurité tant que la capture du texte n'est pas activée explicitement. Voir Attributs de span requis pour les réglages par langage.

Un rapport maigre ou vide n'est donc pas la preuve qu'un service est sain. Cela peut tout aussi bien signifier que les spans n'ont jamais porté ce dont Perf Sentinel a besoin. Sur une entrée OTLP, regardez d'abord analysis.ingest (un filtered_missing_db_statement ou filtered_missing_http_url élevé nomme directement le manque), ou posez min_usable_span_ratio et laissez la gate vérifier pour vous. Auditez votre propre tracing avant de faire confiance à un score bas. Lancez perf-sentinel inspect --input <events.json> (ou query --daemon <URL> inspect contre un daemon en cours) et confirmez que les spans SQL et HTTP apparaissent avec leur texte de requête et leurs URLs. Un arbre de spans clairsemé ou vide est le signal que le coût d'entrée est un travail d'instrumentation, pas un feu vert.

Un manque de qualité de statement est réparé automatiquement, dans certaines bornes : les instrumentations en couches qui scindent une requête entre un span ~0 ms porteur du statement et un span de durée sans statement (PHP Doctrine + PDO) sont recousues en un seul événement à la conversion OTLP. La couture ne s'applique que sur le chemin OTLP (les imports JSON Jaeger et Zipkin gardent l'angle mort), jamais aux datastores non-SQL, qu'aux spans sans statement dont le nom évoque une exécution de requête (execute, query), et qu'au sein d'un même bloc ResourceSpans. Une paire scindée entre deux batchs collector retombe sur le filtrage missing_db_statement décrit ci-dessus. Un span sans statement et sans db.system (la couche Doctrine ne porte le moteur que sur son enfant pdo) n'est recousu que s'il a un frère porteur de statement, de sorte qu'un span enveloppe posé au-dessus de son propre enfant SQL ne s'approprie pas le statement d'un descendant. Les spans fusionnés sont comptés sous la raison merged_db_span.

Comme la couture est bornée à une requête d'export, les requêtes lentes sont son pire cas. Le span prepare d'une requête lente finit immédiatement (exporté dans un batch précoce) alors que son span execute finit des centaines de millisecondes à des secondes plus tard (un batch ultérieur). Le batching de l'exporteur scinde donc la paire entre deux requêtes et l'execute lent retombe sur missing_db_statement. Les requêtes rapides (le motif N+1) ne se scindent pas, elles se cousent donc de façon fiable. Sur les émetteurs à couches séparées (PHP Doctrine) en charge, assez de paires tombent par coïncidence dans le même batch pour que slow_sql se déclenche quand même, mais son compte d'occurrences sous-estime par rapport à un émetteur mono-couche exécutant la même faute. Un opérateur qui a besoin du plein rendement slow_sql pour un tel émetteur peut élargir la fenêtre de batching de l'exporteur au-delà de la requête la plus lente, pour que le prepare et l'execute d'une requête partent dans la même requête d'export. Pour cela, augmentez le timeout (et le send_batch_size) du batch processor du Collector OpenTelemetry, ou le scheduledDelayMillis du BatchSpanProcessor du SDK. Le coût est une latence de findings plus élevée et des batchs d'export plus gros (à garder sous [daemon] max_payload_size, 16 Mio par défaut).

Les datastores non-SQL ne sont pas analysés

Perf Sentinel modélise trois types d'I/O, le SQL relationnel, les appels sortants (HTTP et RPC comme gRPC) et les publications de messages. Un span dont le db.system désigne un datastore non-SQL (redis, memcached, mongodb, cassandra, dynamodb, couchbase, couchdb, elasticsearch, opensearch, neo4j, hbase, geode, influxdb) est écarté à l'ingestion. Son db.statement n'est pas du SQL relationnel et le tokenizer SQL le déformerait (un GET user:123 Redis n'est pas un template de requête). L'écart est décidé sur le seul db.system, donc un span non-SQL est écarté qu'il porte ou non un statement ou une URL, de façon cohérente sur les chemins OTLP, Jaeger et Zipkin. L'écarter est le choix de réduction du risque : cela évite de faux findings N+1 et redundant sur le trafic cache ou document. Un db.system absent ou désignant un moteur SQL (postgresql, mysql, mssql, oracle, clickhouse, cockroachdb, ...) est toujours traité comme du SQL, donc aucun trafic relationnel n'est écarté par erreur.

Comme le span n'entre jamais dans le pipeline, un appel non-SQL écarté est aussi invisible aux détecteurs structurels (fanout excessif, appels sérialisés) : une requête qui éclate en de nombreux appels cache ou document ne lèvera pas ces findings. Sur le chemin OTLP, l'écart est compté sous la raison dédiée non_sql_datastore dans perf_sentinel_otlp_spans_filtered_total, gardée distincte de not_io pour qu'une flotte purement cache ne déclenche pas l'avertissement daemon de rétention nulle, qui ne compte que les raisons de manque d'instrumentation.

Messaging : côté producteur seulement, pas d'analyse des consommateurs

Les spans de broker sont admis à partir de la convention OTel messaging (messaging.system, messaging.destination.name), une seule convention qui couvre en principe indifféremment Kafka, RabbitMQ, Pulsar, SQS, NATS et JMS. Seul le span PRODUCER est admis, comme un appel sortant dont la cible est la destination.

L'admission suit un ordre fixe : un statement SQL gagne, puis une URL sortante (http.url, url.full) sur tout span qui n'est pas SERVER, puis un appelé RPC sur un span CLIENT, puis le messaging. Un émetteur qui pose url.full sur son span de publication, ce que font certaines versions du SDK AWS pour SQS, atterrit donc sur le chemin HTTP, où l'URL de queue passe par le normaliseur de chemins et son identifiant de compte est masqué en {id}. Le chemin messaging est vérifié contre des spans construits à la main pour la forme que décrit la convention, pas contre des captures de l'agent de chaque broker : lisez donc la liste ci-dessus comme les conventions couvertes, pas comme une garantie par agent. Si les spans d'un broker n'apparaissent pas en findings messaging, vérifiez si l'agent pose aussi url.full ou db.system dessus. Un span CONSUMER (process) et un span messaging CLIENT (receive, settle) décrivent un travail effectué sur un message reçu plutôt qu'un appel émis par le service, et un consommateur qui fait du polling noierait les détecteurs d'occurrences sous des publications qu'il n'a jamais faites. Ces spans sont écartés sous la raison not_io. Une exception : un span CLIENT de poll qui porte aussi rpc.*, ce que le SDK AWS pose sur le ReceiveMessage SQS, correspond à la branche RPC au-dessus et est admis comme appel sortant nommé d'après la méthode d'API. Un service purement consommateur se lit donc comme n'ayant aucune I/O messaging, mais peut tout de même montrer des findings HTTP pour sa boucle de polling.

Une publication ne porte aucun paramètre bindé, donc la règle N+1 pour le messaging est le seul compte d'occurrences. N publications vers une même destination dans la fenêtre de détection sont rapportées en n_plus_one_messaging avec un compte de paramètres distincts de 0, puisque la variation vit dans la charge utile et que les charges utiles ne sont jamais lues. C'est plus large que les règles SQL et HTTP, dont la variance des paramètres permet de distinguer une boucle d'une répétition légitime. Un service qui groupe volontairement au niveau applicatif sera signalé, et la suggestion d'utiliser l'API de batch du broker est la lecture attendue. Relevez n_plus_one_threshold si le bruit l'emporte.

Il n'existe pas de type redundant_messaging. La détection de redondance groupe sur (type, template, params), et avec des params toujours vides cette clé se réduirait à la seule destination : deux publications vers une même destination seraient rapportées comme des doublons avec une suggestion "mettre en cache ou dédupliquer". Deux messages ne sont jamais le même message, donc les spans de publication sont entièrement ignorés par ce détecteur. Une publication répétée ressort en n_plus_one_messaging ou pas du tout.

Une hypothèse sous-jacente aux deux règles ne tient pas uniformément selon les brokers. Le pipeline compte un span pour un aller-retour réseau, ce qui est vrai pour un SendMessage SQS et pour un basic_publish RabbitMQ, mais pas pour Kafka. KafkaProducer.send() accumule en mémoire et le client écrit selon batch.size et linger.ms, donc cinq spans de publication peuvent être une seule écriture sur le réseau. Sur Kafka, le compte d'occurrences, le compte d'I/O évitables et le ratio de gaspillage qui en découle sont donc des majorants, et "envoyez-les en un seul lot" peut décrire une optimisation que le client applique déjà. slow_messaging hérite de la même réserve : un span producteur Kafka mesure souvent l'enfilement plutôt que l'acquittement du broker.

Les destinations ne sont pas normalisées. Un nom de topic ou de queue est déjà un template, il est donc comparé tel quel plutôt que passé par le normaliseur de chemins HTTP, qui masquerait les segments numériques en {id} et retirerait l'identifiant de compte d'un ARN de queue SQS. Le compromis est inverse : un déploiement qui nomme un topic par tenant produit un template par tenant, donc une boucle de publication est éclatée entre plusieurs groupes et peut passer sous le seuil. Le sous-groupement est le bon côté de cette erreur, puisqu'il ne fusionne jamais deux tenants dans un même finding.

Le modèle carbone donne à une publication le coefficient par opération non pondéré. Les paliers de taille HTTP ne sont pas réutilisés. Leurs bornes à 10 Ko et 1 Mo encodent une distribution de charges utiles web, et 1 Mo est le max.message.bytes par défaut de Kafka, un plafond protocolaire plutôt qu'un gros message. Les appliquer affirmerait une remise quasi constante qu'aucune mesure ne fonde. messaging.message.body.size reste ingéré pour le jour où un coefficient mesuré existera.

Une voie distincte met malgré tout un chiffre d'énergie de broker dans le rapport, et elle ne passe par aucun coefficient. Déclarer [green.alumet.broker] mesure le cgroup du broker, et [green.broker_static] dérive des watts depuis un cluster déclaré, à son plafond ou depuis sa cpu_query. Dans tous les cas l'énergie est multipliée par le ratio de gaspillage messaging et rapportée en messaging_waste, une énergie d'hôte de broker que le daemon compte dans les totaux dès que son carbone est converti, avec une étiquette model qui dit d'où elle vient. Les bornes ci-dessous se reportent donc dans energy_kwh et co2.total, et sur Kafka le ratio messaging est la borne haute décrite plus haut : la part de gaspillage du broker dans co2.avoidable et dans les niveaux de gaspillage est elle aussi une borne haute, le terme d'hôte lui-même ne l'est pas. Lisez cette étiquette avant de lire le nombre, car les quatre sources ne se trompent pas dans le même sens. alumet_rapl mesure le cgroup du broker via Alumet, dont la formule d'attribution par défaut ne couvre que le CPU, donc il sous-compte. broker_specpower borne les vCPU déclarés à pleine charge, à partir d'une table SPECpower qui couvre le CPU et la carte mère mais ni le stockage, ni le réseau, ni l'alimentation. C'est donc un plafond sur le calcul et non sur la consommation murale du cluster, et un broker limité par le stockage peut consommer davantage. Ce chiffre ne bouge pas non plus quand l'application se met à grouper ses publications, et une longue période d'inactivité est tronquée à une heure de facturation, donc il sous-compte là. cloud_specpower chiffre les hôtes déclarés depuis leur cpu_query sur la même table : il suit donc la charge, laisse toujours de côté le stockage, le réseau et l'alimentation, et porte le retard des métriques CPU et les limites de la requête décrits dans Limites de précision des bases déclarées. estimated est la part côté application des spans de publication, déjà comprise dans les totaux, sans aucune énergie d'hôte de broker. Voir Configuration. L'énergie de transport réseau n'est pas estimée du tout pour le messaging, car l'extraction de nom d'hôte qui la pilote ne comprend que les cibles http:// et https://.

Deux limites tiennent à la destination elle-même. Sur un exchange par défaut RabbitMQ, messaging.destination.name est la chaîne vide : la cible retombe alors sur le nom du span et toutes les clés de routage se replient sur un seul template. Douze publications réparties sur trois clés ont été mesurées comme un finding unique nommé rabbitmq publish, sans rien dans le template sur quoi agir. messaging.rabbitmq.destination.routing_key n'est pas lu. Et quand une trace de consommation porte plusieurs spans receive frères, le lien producteur attribué au travail est le premier dans l'ordre du lot : déterministe, mais arbitraire, puisqu'aucun span ne dit quel message a causé quelle requête.

Trois des déterminants les plus forts de l'énergie messaging réelle sont invisibles depuis un span PRODUCER, ils sont donc structurellement hors de ce que cet outil peut expliquer. Le premier est la position du cluster de brokers sur sa courbe d'utilisation. La puissance mesurée croît avec le débit seulement jusqu'à environ 20 % du maximum d'un cluster Kafka, puis s'aplatit, donc l'énergie marginale d'une publication de plus dépend de la charge du cluster plutôt que de la publication elle-même. Le deuxième est le facteur de réplication, qui décide en combien d'appends physiques se traduit une publication logique. Le troisième est la topologie de distribution, partitions, brokers et groupes de consommateurs. Aucun des trois n'apparaît dans un span côté producteur, et aucune analyse de traces ne les reconstitue. Attribuer une énergie par publication est donc un amortissement d'une puissance d'infrastructure essentiellement fixe, présenté comme tel, et non la mesure du travail causé par un message. Voir 05 · GreenOps & carbon pour les mesures qui fondent ce point.

Comme les spans de publication entrent désormais dans le pipeline, ils comptent aussi dans total_io_ops : le score d'intensité I/O d'un endpoint qui mêle SQL et publications est donc plus élevé que le même trafic scoré par la 0.9.22. C'est un élargissement du périmètre de mesure, pas une régression applicative, et cela compte au moment de comparer des périodes de divulgation de part et d'autre de la montée de version.

Messaging : traces producteur et consommateur reliées, pas fusionnées

Un broker relie un producteur à son consommateur par un span link OpenTelemetry plutôt que par une relation parent-enfant, et le consommateur démarre le plus souvent sa propre trace. Perf Sentinel lit ce lien, depuis l'ancêtre CONSUMER le plus proche d'un span d'I/O, et le porte en link_trace_id sur les événements du handler consommateur. L'arbre explain le rend sous forme d'une ligne triggered by trace <id>, en CLI, dans /api/explain/{trace_id} et dans la TUI. Le dashboard HTML ne le porte pas, car ses spans embarqués n'ont pas ce champ.

Les deux traces ne sont pas fusionnées. Fusionner poserait sur les findings côté consommateur un identifiant de trace qui ne contient pas ces spans une fois collé dans Jaeger ou Tempo. Cela ferait aussi déclencher serialized_calls sur des spans consommateur dont l'ordonnancement appartient au broker et non à l'appelant, et gonflerait chatty_service sur toute chaîne asynchrone. En mode daemon, la fenêtre du producteur est de plus évincée après trace_ttl_ms (30 s par défaut), bien en deçà d'un lag de broker normal, donc une fusion n'attraperait jamais que les chaînes les plus rapides.

La conséquence est que les détecteurs structurels restent à l'intérieur d'une trace. excessive_fanout et serialized_calls voient les publications côté producteur, qui est de toute façon l'endroit où une boucle de publication est détectable, et ne voient rien du côté consommateur du même message. Seul le premier lien d'un span est lu, donc un consommateur qui draine un lot venant de plusieurs producteurs nomme une trace productrice représentative. Un lien qui pointe vers la trace du span lui-même est écarté, donc une valeur dans ce champ signifie toujours qu'une frontière de trace a été franchie.

La résolution du lien producteur s'arrête à la requête d'export. Contrairement à l'ascendance d'endpoint, le daemon ne conserve pas le contexte des liens CONSUMER entre requêtes. L'ancêtre CONSUMER doit voyager dans la même requête OTLP et le même index de service. Le BatchSpanProcessor par défaut enfile un span parent à sa fin, donc après ses enfants. Un handler dont les enfants se terminent juste avant un flush programmé expédie ceux-ci dans un lot et le parent consommateur dans le suivant. Le lien est alors absent, ce qui explique qu'un même handler affiche la ligne sur un run et pas sur le suivant.

Seul le chemin OTLP transporte les liens. Jaeger les modélise en références et Zipkin v2 n'en a pas, donc une trace importée depuis l'un de ces formats n'a jamais de link_trace_id.

Tokenizer SQL

Le normaliseur SQL utilise un tokenizer maison, une machine à états en une seule passe, plutôt qu'un parseur SQL complet. Une regex ne sert qu'à réduire les listes IN (...) une fois le parcours terminé. Ce compromis garde le binaire petit, évite les dépendances lourdes et permet au tokenizer de fonctionner d'un dialecte SQL à l'autre.

  • Pas d'analyse sémantique : les littéraux et UUIDs sont remplacés de manière positionnelle, pas d'AST.
  • Longueur de requête : plafond de 64 Ko, troncature à une frontière de caractère avant la normalisation pour borner la mémoire consommée par une entrée adverse.
  • CTEs (WITH ... AS (...)) prises en charge, y compris imbriquées.
  • Les identifiants entre quotes sont préservés tels quels et les chiffres à l'intérieur ne sont pas confondus avec des littéraux : ANSI "MyTable" et MySQL `table`. Le scan ferme sur le premier délimiteur, donc un délimiteur doublé d'échappement ("", ` `) n'est pas décodé.
  • Les chaînes dollar-quoted ($$body$$, $tag$body$tag$) se réduisent à ?, y compris dans les corps de fonctions.
  • Les nombres couvrent les entiers, les décimaux, les exposants (1e10, 2.5e-3) et l'hexadécimal (0x1F2A). Un signe reste dans le template (-5 devient -?), et un littéral binaire comme 0b101 garde les chiffres qui suivent son b.
  • Une barre oblique inverse dans une chaîne suit le moteur. Les moteurs dont les chaînes suivent la norme la lisent comme un caractère ordinaire : PostgreSQL, CockroachDB et Vertica hors des chaînes E'...', SQL Server, Oracle, DB2, SQLite, H2, HSQLDB, Derby, Trino, Presto et Teradata partout. Tout autre moteur, MySQL et MariaDB compris, s'en sert pour échapper, de même qu'un événement qui ne nomme aucun moteur, la lecture qui protège les données. Trois configurations sont mal lues : un serveur MySQL en mode NO_BACKSLASH_ESCAPES, un serveur PostgreSQL avec standard_conforming_strings désactivé, et une chaîne qui se termine par une barre oblique inverse ('C:\') dans un événement qui ne nomme aucun moteur. La chaîne se prolonge alors et masque le reste de l'instruction, ou le contenu d'une chaîne suivante arrive dans le template.
  • Les instructions CALL normalisent leurs paramètres littéraux, et les expressions SQL comme NOW() et INTERVAL '...' sont gérées.
  • Les identifiants SQL Server [...] ne sont pas traités spécialement ([ est un caractère normal). Les courants comme [Order Details] ou [Col1] restent intacts, tandis qu'un identifiant entre crochets purement numérique comme [123] voit ses chiffres remplacés par ?. Traiter [ comme un caractère normal garde les littéraux et sous-scripts de tableau PostgreSQL (ARRAY['a', 'b'], arr[1]) correctement masqués.

Ce qui reste tel quel dans un template. Le template est ce que portent les rapports, l'API de requête, l'archive NDJSON et le Hub, donc tout ce que les normaliseurs laissent en place voyage avec lui :

  • Le texte entre guillemets doubles est gardé comme un identifiant, sauf sur MySQL et MariaDB, où "..." est un littéral de chaîne masqué comme '...'. Le moteur vient du db.system du span, donc une entrée qui ne le porte pas (le JSON natif, dont operation est un verbe) garde la lecture identifiant. Un serveur MySQL en mode ANSI_QUOTES perd ses identifiants entre guillemets doubles au profit de ?.
  • Les commentaires SQL (-- ..., /* ... */) ne sont pas retirés, donc les mots qu'ils contiennent restent dans le template.
  • Le normaliseur de chemins HTTP masque les segments numériques ({id}) et les UUID ({uuid}) et retire la query string. Tout autre segment reste tel quel : une adresse e-mail, un nom d'utilisateur, un slug ou un jeton opaque dans le chemin arrive dans le template.
  • L'endpoint auquel un finding est rattaché (source.endpoint) vient de http.route quand l'instrumentation le pose. Sans lui, le chemin brut de la requête est gardé, privé de sa query string, de son fragment et de ses identifiants de connexion, et ses segments numériques ne sont pas masqués.

Si vous rencontrez une requête mal normalisée, veuillez ouvrir une issue avec le SQL brut (anonymisé).

Complémentarité avec pg_stat_statements : Perf Sentinel détecte les patterns par trace (N+1, appels redondants) que pg_stat_statements ne peut pas voir. Inversement, pg_stat_statements fournit des statistiques agrégées côté serveur (total d'appels, temps moyen) que Perf Sentinel ne suit pas. Ces statistiques alimentent les classements et la référence croisée seulement, jamais un chiffre d'énergie, de carbone ou de gaspillage. Utilisez les deux pour une visibilité complète.

Paramètres bindés des ORM et classification N+1 vs redundant

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 des paramètres ne sont pas visibles dans l'attribut db.statement/db.query.text. Perf Sentinel voit le template avec les placeholders mais pas les valeurs réelles.

Cela signifie que les patterns N+1 (même requête, valeurs différentes) peuvent être classifiés comme redundant_sql (même requête, mêmes params visibles) au lieu de n_plus_one_sql (même requête, params différents). Les deux findings identifient correctement le pattern de requêtes répétées et la suggestion de regrouper ou de mettre en cache reste valide.

Les ORM qui injectent les valeurs littérales (SeaORM avec des requêtes brutes, JDBC sans prepared statements) produisent des spans avec des valeurs de paramètres visibles, permettant une classification précise N+1 vs redundant.

La même limitation s'applique au RPC (gRPC, Dubbo). La charge utile d'une requête gRPC vit dans le corps du message protobuf, pas dans un attribut de span, donc N appels distincts à une même méthode partagent un unique template à paramètres vides et sont lus comme redundant_http plutôt que n_plus_one_http. Les spans RPC sont aussi modélisés comme des appels HTTP sortants (ils ne portent ni statement ni URL), donc leurs findings sortent sous les types _http avec une remédiation teintée HTTP. Seul le côté sortant SpanKind::Client est admis (le span du handler entrant porte les mêmes clés rpc.* mais représente du travail entrant). Voir Instrumentation.

Redaction de la query string HTTP et visibilité des N+1

La détection des N+1 HTTP dépend de la visibilité du paramètre variable dans le span. Une boucle N+1 qui fait varier un segment de path (GET /api/orders/1, /api/orders/2, ...) se normalise en GET /api/orders/{id} avec des params extraits distincts, et est détectée. Une boucle N+1 qui fait varier un paramètre de query (GET /api/mock?seq=1, ?seq=2, ...) n'est détectée que si la query string arrive intacte jusqu'au span.

Certaines instrumentations masquent 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 la masque à partir de .NET 9 (désactivable avec DOTNET_SYSTEM_NET_HTTP_DISABLEURIREDACTION=true), et le paquet OpenTelemetry.Instrumentation.Http la masque avant .NET 9 (désactivable avec OTEL_DOTNET_EXPERIMENTAL_HTTPCLIENT_DISABLE_URL_QUERY_REDACTION=true). Quand la query est masquée, chaque appel de la boucle porte un url.full identique au byte près, donc Perf Sentinel voit le pattern comme redundant_http (même URL répétée) et non n_plus_one_http (même URL, paramètre différent). Le paramètre variable a été détruit en amont, donc aucun consommateur de traces (Jaeger, Tempo, ou n'importe quel backend OTLP) ne peut le récupérer, pas seulement Perf Sentinel.

Les deux verdicts identifient le pattern d'appels répétés et la suggestion de regrouper les appels reste valide. Pour obtenir n_plus_one_http spécifiquement sur .NET, désactivez le masquage de la query avec la variable qui correspond au framework cible, ou modélisez l'identifiant variable comme un segment de path plutôt qu'un paramètre de query.

Compromis du regroupement par host HTTP

Le template HTTP sortant garde le host de l'appelé (GET user-svc/api/x), donc deux appels au même chemin sur des backends différents restent des groupes séparés au lieu de fusionner en un faux redundant_http. Le coût inverse est qu'un vrai N+1 peut se scinder si un même backend est adressé avec une orthographe de host incohérente dans une trace. Les IP littérales IPv4/IPv6 sont retirées (les replicas load-balancés continuent de se dédupliquer), le point final DNS est canonicalisé (svc. == svc) et le host est mis en minuscules. Un nom court et sa forme pleinement qualifiée (user-svc vs user-svc.default.svc.cluster.local) ne sont en revanche pas réconciliés, donc une boucle qui mélange les deux orthographes contre un même backend peut se scinder en groupes sous le seuil et passer inaperçue. De même, une boucle par item qui éclate vers des backends aux noms distincts sur le même chemin (GET shard-1/lookup, GET shard-2/lookup, ...) est traitée comme des opérations distinctes plutôt qu'un N+1, puisque ces appels ne peuvent pas être regroupés en un seul endpoint. Émettre une orthographe de host cohérente par appelé garde le regroupement N+1 intact.

Findings lents et ratio de gaspillage

Les findings lents (slow_sql, slow_http) représentent des opérations qui sont nécessaires mais lentes : ce ne sont pas des I/O évitables. Par conséquent, les findings lents ne contribuent pas au ratio de gaspillage I/O ni au compteur avoidable_io_ops dans le résumé GreenOps. Ils apparaissent tout de même dans la liste des findings avec green_impact.estimated_extra_io_ops: 0.

Le ratio de gaspillage mesure combien d'I/O pourraient être éliminées (N+1, redondant), tandis que les findings lents mettent en évidence des opérations nécessitant une optimisation (indexation, cache) plutôt qu'une élimination. L'énergie ne lit pas non plus duration_us : une requête lente pèse une opération dans chaque chiffre d'énergie, donc le coût d'hôte d'une requête longue n'est pas modélisé.

Commandes de session et ratio de gaspillage

Les commandes de gestion de connexion et de session suivent la même règle, pour la même raison. Un driver poolé en émet une par acquisition de connexion : une requête qui en emprunte N affiche N COMMIT, SET ou SELECT set_config(?, ?, false) identiques. Les détecteurs de répétition les ignorent (normalize::sql::is_session_command), elles ne deviennent donc jamais un n_plus_one_sql, un redundant_sql, ni un maillon d'une chaîne serialized_calls : aucune remédiation par batch, cache ou parallélisation ne s'applique au contrôle transactionnel.

Elles restent des opérations d'I/O et demeurent dans total_io_ops, dans l'attribution par service et dans le total carbone, parce que ce travail a bien lieu. Elles sortent en revanche de avoidable_io_ops, dérivé des findings. Un déploiement qui monte de version voit donc baisser io_waste_ratio, ainsi que estimated_optimization_potential_kgco2eq et l'énergie évitable publiée, sans que l'application ait changé. Un seuil [thresholds] io_waste_ratio_max calibré avant ce changement devient plus permissif que prévu et doit être recalibré.

L'exclusion est étroite par construction : elle ne couvre que ce qu'un driver émet une fois par acquisition de connexion. Les statements émis une fois par ligne ou par entité restent visibles, puisque les regrouper est un vrai correctif. SELECT 1 n'est pas traité comme un ping de pool (il se normalise vers le même template que la sélection de n'importe quel littéral). Ce n'est pas non plus le cas de SHOW COLUMNS (introspection de schéma, une par entité), de SELECT @@IDENTITY (clés générées, une par insertion) ni du bloc anonyme Oracle BEGIN pkg.proc(?); END; (un appel de procédure stockée, et un N+1 de ces appels est réel).

Interprétation des scores

La CLI affiche un qualificatif (healthy / moderate / high / critical) à côté de io_intensity_score et io_waste_ratio et la même classification est émise dans le rapport JSON sous forme de champs frères io_intensity_band et io_waste_ratio_band. Les tables de référence sont dans le README principal.

Pourquoi ces seuils

BandeAncrage
IIS_MODERATE 2.0Règle approximative, un endpoint CRUD typique fait 1-2 opérations I/O
IIS_HIGH 5.0n_plus_one_threshold par défaut, le point où detect_n_plus_one commence à émettre des findings
IIS_CRITICAL 10.0L'escalade de sévérité indices.len() >= 10 dans detect::n_plus_one, le même nombre déclenche Severity::Critical
WASTE_HIGH 0.30Correspond à la valeur par défaut de io_waste_ratio_max. La gate est une politique utilisateur et l'interprétation une heuristique fixe. Elles restent indépendantes pour qu'une gate relâchée ne masque pas silencieusement le signal
WASTE_CRITICAL 0.50Au moins la moitié de l'I/O analysée est du gaspillage évitable

Contrat de stabilité

Les valeurs d'enum (healthy, moderate, high, critical) sont stables entre versions. Les consommateurs en aval peuvent donc fonder leur logique dessus. Les seuils numériques sont versionnés avec le binaire et peuvent évoluer. Un consommateur qui a besoin d'une classification indépendante de la version (par exemple une alerte Grafana) doit lire les champs bruts io_intensity_score / io_waste_ratio et appliquer ses propres bandes.

Sévérité par détecteur

Les règles Critical / Warning / Info par détecteur se trouvent dans 04 · Détection, avec les seuils par détecteur (certains réglables par configuration : max_fanout × 3, chatty_service_min_calls × 3).

La détection de fanout nécessite parent_span_id

La détection de fanout (excessive_fanout) repose sur le champ parent_span_id pour construire les relations parent-enfant entre les spans. Si l'instrumentation de tracing ne propage pas les IDs de span parent (certains anciens SDKs OTel ou instrumentations personnalisées), la détection de fanout ne produira pas de findings.

Les findings de fanout, comme les findings lents, ne sont pas comptés comme des I/O évitables dans le ratio de gaspillage. Ils représentent un problème structurel (trop d'opérations enfants par parent) plutôt que des I/O éliminables.

La résolution d'endpoint est bornée

Dans une requête OTLP, source.endpoint est la route HTTP entrante la plus externe de la chaîne de parents contiguë d'un service.name explicite. Les index de parents couvrent tous les blocs ResourceSpans de ce service mais ont pour clé à la fois l'id de trace et l'id de span : ils n'entrent donc pas en collision entre traces et ne franchissent pas la frontière du service appelant. Une ressource anonyme ne peut pas prouver que deux blocs appartiennent au même service et se limite à la route la plus proche de son propre bloc.

Sans route sur cette chaîne, le cadre code.* applicatif le plus externe nomme le point d'entrée. Sans cadre non plus, c'est la destination du span CONSUMER le plus proche qui le nomme, sous la forme <messaging.system> <destination> (rabbitmq crm.dossiers), messaging.destination.template avant le nom et le nom du système en minuscules. Ce que porte la destination dépend de l'instrumentation. Avec ses conventions de messagerie par défaut, les spans consumer spring-rabbit de l'agent Java OpenTelemetry portent la clé de routage, ses spans de livraison amqp-client seulement l'exchange, donc un service qui lit un seul exchange par ces derniers uniquement entre de nouveau en collision sous ce nom. Une destination marquée temporaire ou anonyme, une file nommée par le serveur (amq.gen-*, spring.gen-*), un substitut entre chevrons tel que <default> (qu'une instrumentation émet pour un exchange par défaut) ou une destination contenant ?, # ou @ ne nomme aucune origine stable et laisse "unknown". Une instrumentation peut ne poser aucun des deux indicateurs (l'agent Java OpenTelemetry entre autres), raison pour laquelle les noms générés par le serveur sont aussi filtrés. Jaeger et Zipkin suivent le même ordre.

En mode daemon, les ids de span OTLP valides et échantillonnés, leurs liens parent, leurs routes entrantes et leurs destinations de consumer issus de services explicitement nommés sont aussi retenus dans la TraceWindow. Le contexte anonyme n'est pas conservé entre exports. Ce contexte borné permet à une route ou à un span consumer arrivé dans un export ultérieur de réparer un événement I/O antérieur, le cas habituel d'un consumer, qui se termine après les enfants qu'il englobe, sans événement synthétique ni incrément des métriques I/O. Le ring d'événements, les contextes de route retenus, les destinations de consumer retenues et l'index d'ascendance sont chacun plafonnés par max_events_per_trace et partagent le LRU et le TTL des traces. Une destination de consumer retenue ne remplit que ce qu'aucune route ni aucun ancêtre résolu ne résout. Un endpoint porté par un événement I/O qu'aucune route retenue n'a confirmé, cadre ou destination, est mis en cache comme non prouvé : il nomme ce que rien de prouvé ne nomme et ne l'emporte jamais sur une route plus proche. Avec un plafond d'ascendance de un, une destination retenue empêche aussi de deviner la seule route retenue. Les cadres portés par des spans sans I/O ne sont pas retenus d'un export à l'autre, donc une trace répartie sur plusieurs exports peut afficher une destination là où un seul export afficherait un cadre. Toute remontée s'arrête après exactement huit sauts.

L'attribution peut donc encore se dégrader vers la route prouvée la plus proche ou "unknown" après des identifiants invalides, un service.name absent, l'échantillonnage, la limite de huit sauts, une rotation par trace, une éviction LRU ou l'expiration TTL. Les commandes batch ne retiennent aucun contexte entre deux requêtes d'export distinctes. Augmenter les plafonds ou la rétention peut récupérer davantage de contexte tardif, au prix d'une enveloppe mémoire bornée plus élevée.

rss_peak_bytes sous Windows

La commande perf-sentinel bench rapporte le RSS pic (Resident Set Size) en utilisant des APIs spécifiques à la plateforme. Sous Windows, cette métrique est rapportée comme null car l'implémentation actuelle utilise getrusage() qui est spécifique à Unix. Les métriques de débit et de latence fonctionnent sur toutes les plateformes.

Échantillonnage en amont et précision de la détection

L'échantillonnage en amont s'applique avant Perf Sentinel, dans le SDK ou le collecteur. Il est distinct du réglage sampling_rate propre au daemon, traité plus bas.

L'échantillonnage head-based garde ou écarte une trace entière à sa racine. Les traces conservées arrivent complètes, donc les détecteurs par trace (n_plus_one, chatty_service, excessive_fanout, pool_saturation, serialized_calls) restent corrects sur les traces que Perf Sentinel voit : un N+1 dans une trace conservée reste entièrement visible. Ce qu'un échantillonnage head-based agressif (le classique 1% à 10% en production, pour le coût) dégrade, c'est la couverture, pas la structure par trace :

  • Les patterns rares peuvent ne jamais apparaître. Un pattern qui ne survient que dans une petite fraction du trafic peut être entièrement écarté et ne jamais atteindre la détection.
  • Les agrégats sont calculés sur un sous-ensemble non représentatif. Le ratio de gaspillage I/O et les compteurs Prometheus ne reflètent que les traces échantillonnées, donc ils ne peuvent pas se lire comme des chiffres sur l'ensemble du trafic.
  • La corrélation cross-trace cesse de fait de produire. Le corrélateur cross-trace a besoin qu'une paire de findings se répète (min_co_occurrences, défaut 5) dans sa fenêtre. À bas taux d'échantillonnage, les co-occurrences répétées survivent rarement, donc le corrélateur reste silencieux même quand le couplage sous-jacent est réel.

Perf Sentinel n'inspecte pas le flag W3C sampled et ne distingue pas une trace complète d'une survivante d'un échantillonnage head-based. Il traite ce qui arrive comme la trace complète.

Recommandations :

  • Pour les quality gates CI, utilisez le mode batch (perf-sentinel analyze) sur des traces intégralement capturées. Une gate ne doit pas décider sur 1% du trafic.
  • Dans le daemon, si vous devez échantillonner pour le coût, préférez un échantillonnage tail-based au niveau du collecteur. Le tail-based garde lui aussi des traces entières, mais permet de biaiser la rétention vers les traces lentes ou en erreur, là où le gaspillage structurel se concentre.
  • Mieux encore, n'échantillonnez pas du tout la branche qui alimente Perf Sentinel. L'échantillonnage borne ce qu'un magasin de traces conserve, et Perf Sentinel ne conserve rien au-delà d'une fenêtre par trace en mémoire, donc un second pipeline de collector exportant vers le daemon sans le sampler ne coûte rien en stockage. Voir Déploiement Helm pour la disposition.
  • Le tail-based ne répare pas les agrégats, seulement la structure par trace. Pire pour l'interprétation, les politiques errors et slow biaisent la rétention vers les traces lourdes, donc l'échantillon survivant n'est pas représentatif et aucun facteur unique ne le corrige. Ne publiez pas comme chiffre de trafic complet un ratio de gaspillage ou une empreinte carbone calculés derrière un sampler, et traitez une sortie disclose issue d'une fenêtre échantillonnée comme une sous-estimation.

Échantillonnage en mode daemon

Ceci est le réglage d'échantillonnage propre à Perf Sentinel, appliqué après l'ingestion, distinct de l'échantillonnage en amont décrit ci-dessus. Lorsque sampling_rate est défini en dessous de 1.0 dans la configuration [daemon], Perf Sentinel supprime aléatoirement des traces pour réduire l'utilisation des ressources. Cela signifie :

  • Certains patterns N+1 ou redondants peuvent ne pas être détectés
  • Le ratio de gaspillage est calculé uniquement sur les traces échantillonnées et peut ne pas représenter l'ensemble du trafic
  • Les métriques Prometheus (perf_sentinel_traces_analyzed_total) ne reflètent que les traces échantillonnées

Pour une détection précise, utilisez sampling_rate = 1.0 (le défaut) ou échantillonnez au niveau du collecteur où vous avez plus de contrôle.

Contrairement à l'échantillonnage amont, celui-ci, le daemon le voit. Sous 1.0 il émet une entrée tuning dans Report.warning_details à chaque appel /api/export/report, qui nomme le taux et indique quels nombres décrivent un échantillon. Le périmètre est plus étroit qu'il n'y paraît. La suppression est un hachage uniforme sur les trace ids, sans biais de politique, donc un ratio construit des deux côtés sur des traces échantillonnées (le ratio de gaspillage I/O en particulier) reste un estimateur non biaisé. Il en va de même du gaspillage BDD, qui est l'énergie de l'hôte multipliée par un tel ratio. Ce qui rétrécit, ce sont les comptes absolus, et la couverture. Un taux agressif reste le mauvais réglage pour un rapport destiné à être publié, parce qu'un pattern jamais échantillonné ne contribue ni à l'un ni à l'autre.

Nombre maximum d'événements par trace

En mode streaming, chaque trace contient au maximum max_events_per_trace événements (défaut : 1000) dans un buffer circulaire. Si une trace génère plus d'événements, les plus anciens sont supprimés. Cela peut causer :

  • Des patterns N+1 manqués si les opérations répétées tombent en dehors de la fenêtre conservée
  • Un sous-comptage des occurrences dans les findings

Pour les traces avec un très grand nombre d'événements, augmentez max_events_per_trace ou cherchez pourquoi une seule trace génère autant d'opérations.

Traces longues et éviction TTL en mode daemon

La fenêtre de détection en streaming évince une trace lorsqu'elle est restée inactive pendant trace_ttl_ms (défaut 30s). "Inactive" signifie qu'aucun événement de span pour ce trace_id n'a été ingéré dans le TTL. Le TTL actif est réinitialisé à chaque ingestion de span, donc une trace qui émet un span toutes les <30s reste vivante indéfiniment.

Mais une trace qui émet des spans creux et espacés (par exemple un job batch long qui émet un span toutes les 60s, ou un websocket en long polling) sera évincée entre les rafales. Un span tardif portant le même trace_id qui arrive après l'éviction crée un nouveau bucket de trace. Les événements précédents sont perdus. Les détections pilotées par seuil qui s'appuient sur des spans co-localisés dans une même trace (n_plus_one, chatty_service, excessive_fanout, pool_saturation, serialized_calls) sous-rapportent silencieusement parce que chaque fragment passe sous le seuil par trace.

Atténuations, par ordre de précision :

  • Augmentez trace_ttl_ms si vous connaissez l'écart maximum attendu entre rafales ([daemon] trace_ttl_ms = 120000 pour 2 minutes). La mémoire croît avec max_active_traces, pas avec le TTL : un TTL plus long ne coûte rien tant que le profil de trafic ne dépasse pas le plafond LRU.
  • Utilisez le mode batch (perf-sentinel analyze) sur un dump de trace capturé pour une investigation hors-ligne. La corrélation batch n'a pas de frontière TTL. La trace entière est corrélée en une seule passe.
  • Raccourcissez la trace en amont. Si une trace est conceptuellement longue parce qu'elle couvre plusieurs actions utilisateur, envisagez de la découper côté application (une trace par requête logique).

C'est une propriété de la fenêtre streaming, pas un bug. La détection temps réel sur un buffer circulaire borné troque toujours durée de trace contre mémoire. Le daemon retient 30s comme défaut adapté aux profils request-response classiques (API HTTP, RPC).

Contre-pression d'analyse et délestage de charge

detect+score tournent sur un unique worker d'analyse dédié, découplé de la boucle select! d'ingestion / éviction par un canal borné (1024 lots). La boucle enfile sans bloquer les lots évincés et expirés, donc une passe d'analyse lente ne peut pas bloquer l'ingestion ni retarder l'éviction TTL. En contrepartie, lorsque l'analyse ne suit pas sous charge soutenue, la file se remplit et des lots entiers sont délestés. Le délestage est explicite et métré, pas silencieux :

  • perf_sentinel_analysis_queue_depth expose le backlog courant. Une valeur non nulle durable signifie que le worker prend du retard.
  • perf_sentinel_analysis_shed_batches_total et perf_sentinel_analysis_shed_traces_total comptent ce qui a été abandonné. Alertez sur rate(perf_sentinel_analysis_shed_batches_total[5m]) > 0.

Un lot délesté est totalement écarté de la détection : ses findings ne sont jamais émis et le corrélateur cross-trace ne le voit jamais. Comme Perf Sentinel fait remonter des motifs récurrents, un N+1 ou un chemin bavard délesté est normalement redétecté à la requête suivante une fois le worker rattrapé. Un délestage soutenu signale un daemon sous-dimensionné pour le volume de traces : répartissez la charge sur plusieurs instances (shard par trace_id), gardez de la marge via sampling_rate, ou réduisez le coût par trace en amont.

Le délestage répond à la surcharge, pas à une panne. Si le worker d'analyse lui-même s'arrête (par exemple un détecteur qui panique sur une trace pathologique), le daemon ne reste pas debout à n'analyser plus rien : il sort en erreur pour qu'un superviseur (Kubernetes, systemd) redémarre le process. C'est le même comportement fail-loud que l'ancienne détection en ligne, où une panique faisait planter tout le daemon. Tout lot enfilé dans la brève fenêtre avant la sortie est compté comme délesté plutôt que perdu silencieusement.

Un arrêt gracieux ne déleste pas : il vide la fenêtre et joint le worker afin que chaque lot en vol soit analysé avant la sortie.

Le délestage d'analyse est distinct de la rétention d'archive. L'archive de divulgation par fenêtre (daemon/archive.rs, le NDJSON que disclose agrège ensuite) a son propre canal borné avec une politique explicite de rejet quand il est plein. Sous charge soutenue, ou si la tâche d'écriture prend du retard sur les I/O disque, des fenêtres entières sont abandonnées de l'archive même quand leurs findings ont été analysés et servis sur les endpoints en direct. Chaque perte est journalisée et comptée sur perf_sentinel_archive_windows_dropped_total, et ce compteur est le seul témoin après coup. La chaîne de hachage n'avance que sur les écritures réussies, donc une fenêtre abandonnée ne laisse aucun trou dans seq et la vérification de hachage ne peut pas la révéler. Le compteur a lui-même deux angles morts. Une tâche d'écriture qui meurt jette son arriéré en file, jusqu'à 256 fenêtres, sans aucune incrémentation. Un arrêt brutal (SIGKILL, OOM) qui tombe avant la fin du vidage perd des fenêtres de la même façon silencieuse. Un arrêt gracieux vide et joint bel et bien le canal d'archive, exactement comme il vide le worker d'analyse, donc l'arriéré est écrit sur une sortie propre. Ce qu'une sortie propre ne peut pas restaurer, ce sont les fenêtres déjà perdues avant elle, et une écriture qui échoue pendant ce vidage final reste perdue. L'archive fonctionne au mieux (transparence publique, pas de niveau réglementaire), donc considérez-la comme un enregistrement échantillonné plutôt qu'un registre complet, et lisez un taux de pertes non nul comme une archive qui sous-déclare la période.

Modèle d'état du daemon, en mémoire, mono-processus, sans état partagé

L'état de corrélation du daemon est entièrement en mémoire : une fenêtre glissante de 30s (trace_ttl_ms) sur un LRU de 10 000 traces (max_active_traces), tous deux réglables sous [daemon]. Il n'y a pas de couche de persistance, pas de write-ahead log, pas de snapshot, pas de débordement sur disque. Cela façonne trois propriétés opérationnelles.

Un arrêt gracieux draine, un kill non gracieux non. Sur un arrêt propre, le daemon draine sa fenêtre à travers la détection avant de quitter. SIGINT (Ctrl+C) et, sous Unix, SIGTERM déclenchent tous deux ce drain, donc une terminaison de pod Kubernetes normale (rolling update, scale-down) draine la fenêtre en vol au lieu de la jeter. Une mort non gracieuse la perd quand même : SIGKILL (le kill forcé du kubelet après la période de grâce de terminaison), un OOM kill ou un crash du processus sautent le drain et jettent les traces en vol, jusqu'à une fenêtre entière, sans reprise.

L'impact pratique est faible. Ce qui est en vol à cet instant, ce sont des traces incomplètes (elles n'ont pas atteint leur TTL, donc elles reçoivent peut-être encore des spans). Perf Sentinel fait aussi remonter des patterns récurrents : un N+1 ou un chemin bavard que la fenêtre jetée aurait signalé réapparaît à la requête suivante et est capturé par le nouveau processus en quelques secondes. Les données de trace ne sont pas perdues non plus : elles vivent en amont dans votre collecteur ou votre store de traces. Les acknowledgments runtime sont sur disque et survivent (voir mode StatefulSet). Le seul endroit où un trou est visible est l'archive NDJSON par fenêtre (opt-in), qui manque la fenêtre en vol au moment du kill. Si cela compte, gardez trace_ttl_ms court, ou exécutez les gates en mode batch où il n'y a pas de fenêtre à perdre.

Les replicas ne partagent pas d'état. Chaque instance du daemon est indépendante : sa propre fenêtre, ses propres métriques, son propre corrélateur. Le chart Helm expose workload.replicas, mais il n'y a ni leader election ni store partagé. Deux replicas qui analysent le même service calculent deux vues partielles, jamais une vue fusionnée. Les compteurs Prometheus sont par replica et doivent être agrégés au niveau PromQL.

La corrélation suppose des spans co-localisés. Les détecteurs par trace et le corrélateur cross-trace sont une structure par processus. Ils supposent que chaque span d'une trace donnée, et chaque trace liée, atterrit dans le même daemon. Conséquences pour le passage à l'échelle horizontal :

  • Les détecteurs par trace (n_plus_one et consorts) sont corrects avec plusieurs replicas seulement si le collecteur répartit par trace_id afin que toutes les spans d'une trace atteignent la même instance. Le loadbalancingexporter du Collector OTel avec routing_key: traceID fait cela, et la topologie shardée en dépend.
  • La corrélation cross-service n'a pas de réponse distribuée aujourd'hui. Elle ne voit que ce qu'un processus met en tampon, donc elle doit tourner sur un daemon unique qui reçoit tous les services concernés, ou vous acceptez une corrélation partielle. La répartition par trace_id n'aide pas ici, car la corrélation cross-service couvre des traces différentes.

Registre de détections : ce que couvre un compte

Depuis 0.37.0, le daemon compte combien de fois il a détecté chaque signature de finding par jour UTC, servi sur GET /api/detections et poussé par [daemon.hub_export]. Le compte est exact pour ce que le daemon a analysé, et ne dit rien de ce qu'il n'a pas vu. Lisez-le avec ces limites.

L'unité est un finding émis. Une détection est comptée chaque fois que la détection émet un finding doté d'une signature, avant que l'anneau de findings, l'export vers le Hub et les acquittements ne le voient. max_retained_findings (même à 0), l'éviction de l'anneau, la suppression d'une heure de [daemon.hub_export] et les acks ne changent pas le compte. Le seen_count de /api/findings reste ce qu'il était : les détections que l'anneau retient encore, pas un volume. Conséquences :

  • Un finding acquitté compte quand même, comme son gaspillage reste dans io_waste_ratio.
  • Une trace peut produire plusieurs findings d'une même signature, et chacun compte : des findings redundant_* à paramètres différents, excessive_fanout et serialized_calls une fois par span parent, un finding par valeur de grouping.
  • Les findings lents inter-traces et ceux de la fenêtre glissante des lents comptent, avec 0 I/O évitable.
  • Un fragment de trace analysé de nouveau après une éviction TTL compte tel que le détecteur l'a émis.
  • extra_io_ops est la somme exacte des green_impact.estimated_extra_io_ops de chaque détection, pas le compte multiplié par un représentant. Il est absent quand le scoring green est coupé.

Mode strict, sans persistance. Le registre vit en mémoire seulement. Tout arrêt du daemon, propre ou non, est un trou : rien n'est analysé pendant qu'il est arrêté, et il ne peut pas savoir si le collecteur a rejoué des spans entre-temps. Chaque démarrage ouvre un nouveau registre avec un nouveau ledger_id, et son premier jour porte gap = true. Un arrêt gracieux met gap = true sur le jour courant une fois le dernier lot analysé. Un jour où le daemon s'est arrêté avant minuit n'est jamais scellé, il reste donc incomplet pour de bon. Le registre garde [daemon.detections] retained_days jours (8 par défaut) : un lecteur qui ne revient pas dans ce délai perd les plus anciens.

Pas d'extrapolation. Les comptes couvrent ce qui a atteint la détection. Chaque jour rapporte le plus petit sampling_rate en vigueur, les traces délestées par l'analyse (shed_traces) et les requêtes OTLP refusées pour file pleine ou pression mémoire (ingest_rejected). Aucun d'eux n'est rajouté aux comptes. Une nouvelle signature au-delà de max_signatures_per_day, ou une signature de plus de 1024 octets ou contenant un caractère de contrôle, compte dans l'overflow du jour, sans sa signature.

Certaines pertes ne sont pas signalées. Les pertes d'ingestion qu'aucune métrique ne compte aujourd'hui ne laissent aucune marque sur le jour : un corps OTLP HTTP refusé en 413, un message gRPC trop gros, une ligne rejetée sur le socket JSON, les événements qu'une trace perd au-delà de max_events_per_trace. Avec l'une d'elles, un jour peut se lire complet et rester une borne basse.

Plusieurs pods derrière un même Service. Chaque daemon tient son propre registre. Un Hub qui interroge un Service atteint un pod par requête, il ne lit donc jamais le registre de chaque pod, et il marque les comptes qu'il en tire comme incomplets (multi_instance). Deux montages donnent des comptes complets : une source du Hub par pod, chacune interrogeant l'adresse propre de son pod, ou [daemon.hub_export] sur chaque pod, pour que chaque pod pousse son propre registre et que le Hub les additionne. Voir Déploiement Helm.

Ce que couvre below_threshold_passes. Il compte les passages d'une signature surveillée restés sous son seuil, et peut en manquer. Une signature de fanout construite sur le template enfant dominant ne correspond qu'à une partie de ses parents. Un parent sérialisé hors de la trace est ignoré. La liste surveillée se perd au redémarrage du daemon jusqu'à la réponse de la poussée suivante, et un daemon que le Hub ne fait qu'interroger ne reçoit aucune liste. Un finding lent inter-traces prend son service et son endpoint de sa pire span, donc le même template sous un autre endpoint n'est pas compté. Tous ces cas donnent des passages manqués, jamais de faux.

Ce que coûte le comptage des passages. Le daemon ne compte les passages que tant que la liste surveillée du Hub contient des signatures, c'est-à-dire tant qu'un correctif sur un seuil attend sa confirmation. Une liste vide ne coûte rien. Sur le corpus synthétique de 10k événements du bench, la détection prend 5,9 à 6,6 % de plus avec dix signatures surveillées de plusieurs types, 5,4 à 6,3 % avec dix signatures lentes, et environ 35 % de plus quand toutes les signatures à seuil du corpus sont surveillées. L'essentiel vient de la comparaison du service et de l'endpoint de chaque trace avec la liste, que toutes les traces paient.

Limites de longueur des champs à l'ingestion

Toutes les frontières d'ingestion (OTLP, JSON, Jaeger, Zipkin) tronquent les champs texte pour empêcher une croissance mémoire non bornée due à des attributs surdimensionnés. Limites : service 256 octets, operation 256 octets, target 64 Ko, source.endpoint 512 octets, source.method 512 octets, timestamp 64 octets, trace_id/span_id 128 octets. La troncation préserve les frontières de caractères UTF-8. Les champs en dessous de la limite ne sont pas modifiés (chemin rapide sans copie).

Taille du binaire

Le binaire release cible < 15 Mo avec lto = "thin", strip = true et panic = "abort". La table d'intensité carbone embarquée et la prise en charge du protobuf OTLP contribuent à la taille du binaire. Si vous avez besoin d'un binaire plus petit et n'utilisez pas l'ingestion OTLP, la compilation avec des feature flags (travail futur) pourrait réduire la taille.

Dashboard HTML : guard formula-injection CSV

Chaque cellule des CSV exportés par le bouton Export CSV par onglet du dashboard HTML est vérifiée contre l'OWASP CSV injection. Si le premier caractère d'une cellule est =, +, -, @, ou une tabulation horizontale (\t), une apostrophe simple est préfixée pour qu'Excel, LibreOffice Calc et Google Sheets affichent le texte littéral plutôt que l'évaluer comme une formule à l'ouverture. Le préfixe est invisible dans la vue tableur et ne modifie pas la donnée pour les consommateurs qui parsent le CSV en texte brut. Les caractères déclencheurs ne sont neutralisés qu'en position 0, donc un template légitime comme abc=def s'exporte inchangé.

Pas d'authentification (TLS disponible, auth non intégrée)

Perf Sentinel n'implémente pas d'authentification sur ses endpoints d'ingestion. Par défaut, le daemon écoute sur 127.0.0.1 (loopback uniquement), ce qui est sûr pour les déploiements sur une seule machine.

Le TLS est pris en charge sur les listeners OTLP gRPC et HTTP via les champs de configuration [daemon] tls_cert_path et tls_key_path. Lorsque les deux sont renseignés, le daemon sert OTLP et /metrics en TLS. Le socket unix JSON et le scraping Prometheus /metrics ne sont pas configurables séparément : /metrics partage le port HTTP et hérite de son paramètre TLS. Voir Configuration pour la référence complète.

Si vous exposez Perf Sentinel sur un réseau :

  • Activez le TLS via tls_cert_path et tls_key_path pour chiffrer le trafic en transit
  • Utilisez des politiques réseau (Kubernetes NetworkPolicy, isolation réseau Docker, règles de pare-feu) pour restreindre l'accès
  • Pour l'authentification, placez Perf Sentinel derrière un reverse proxy (nginx, envoy) qui gère les tokens bearer ou les certificats client mTLS
  • Acheminez les traces via un OpenTelemetry Collector avec ses propres extensions d'authentification et transmettez à Perf Sentinel sur un réseau interne de confiance

N'exposez jamais Perf Sentinel directement sur des réseaux non fiables sans au minimum le TLS activé et des contrôles d'accès réseau en place.

La porte de lecture est une clé partagée

[daemon] require_read_key = true met chaque lecture de l'API de requête derrière [daemon] read_api_key (ou la clé d'écriture qu'une route prend déjà), voir API de query. C'est un secret partagé unique, pas une identité : qui le détient lit les findings, traces et rapports de chaque service, et le daemon ne distingue pas un lecteur d'un autre ni ne restreint l'un d'eux à une équipe. Les lectures par personne ou par équipe relèvent d'un proxy en frontal, ou de PerfSentinelHub. La porte couvre la seule API de requête : l'ingestion OTLP, /metrics, /health et la socket JSON restent comme avant. Chaque lecteur a alors besoin de la clé, et une version du plugin JetBrains qui n'envoie pas de secret par endpoint reçoit 401 à chaque rafraîchissement, donc n'activez pas l'interrupteur sur un daemon que lisent les IDE des développeurs avant que le plugin qui y est installé puisse en envoyer un.

Durcissement du socket JSON

Le socket unix JSON ([daemon] json_socket) se défend contre les attaques locales sur un hôte multi-utilisateurs avec trois mécanismes :

  • Permissions 0o600 appliquées juste après bind(). Les autres utilisateurs locaux ne peuvent pas se connecter pour injecter des événements. Fixer l'umask avant bind() demanderait du code unsafe, le socket porte donc le mode par défaut pendant l'instant qui sépare les deux appels.
  • Pré-vérification du propriétaire : avant que le daemon ne supprime un socket résiduel au chemin configuré, il appelle symlink_metadata() et ne retire l'entrée que si c'est un socket appartenant à l'utilisateur du daemon. Un lien symbolique, un fichier ordinaire ou le socket d'un autre utilisateur arrête le démarrage. Cela empêche un attaquant local ayant un accès en écriture au répertoire parent du socket de faire pointer json_socket vers un fichier victime (par exemple /etc/passwd) et de laisser le remove_file() de démarrage du daemon le supprimer, ou d'y poser son propre socket avant que le daemon démarre.
  • Échec fermé : une entrée refusée par la pré-vérification, un bind() ou un chmod en échec arrête le daemon au démarrage avec failed to bind JSON socket, au lieu de le laisser tourner sans socket pendant que les producteurs continuent d'écrire vers qui détient le chemin. Un répertoire parent absent est la seule exception : le listener est désactivé et le daemon démarre, le cas attendu dans une image FROM scratch sans /tmp.

Ces défenses ne comptent que si json_socket se trouve dans un répertoire accessible en écriture par d'autres utilisateurs locaux. Si vous placez le socket dans un répertoire appartenant au daemon (/var/run/perf-sentinel/ avec 0o700), la surface est déjà fermée au niveau du système de fichiers.

Budget payload par connexion sur le socket JSON

[daemon] max_payload_size (défaut 1 Mio) plafonne les batches NDJSON individuels soumis au socket JSON. Une seule connexion peut transmettre plusieurs batches avant de se fermer et le daemon tolère jusqu'à 16× max_payload_size par connexion avant de tronquer le flux. Avec les valeurs par défaut, cela veut dire qu'une connexion peut transférer jusqu'à 16 Mio de données de traces.

Ce facteur prévoit le cas des clients qui émettent beaucoup de petits batches sur une connexion longue durée (par exemple un sidecar qui vide une file d'attente bufferisée après un flush), sans exposer le daemon à un épuisement mémoire provoqué par un attaquant. Un client qui a besoin de plus de 16× la taille de batch configurée doit ouvrir une nouvelle connexion. Le plafond ne peut pas être désactivé.

Cap de concurrence sur les handshakes TLS

Chaque listener TLS (OTLP gRPC et OTLP HTTP) limite à 128 les handshakes en vol et les connexions HTTPS actives simultanées. Les handshakes tournent dans des tâches dédiées pour qu'un seul pair figé ne bloque pas la boucle d'accept. Le plafond borne les fds, les buffers rustls et les emplacements de tâches face à un afflux de handshakes. Un timeout de 10s (TLS_HANDSHAKE_TIMEOUT) coupe les pairs qui terminent le TCP sans envoyer de ClientHello. Le plafond n'est pas configurable et s'aligne sur le budget du socket JSON Unix.

Sous-commandes query-API : la valeur d'endpoint doit être de confiance

Les subcommands tempo et jaeger-query effectuent tous deux des requêtes HTTP sortantes vers un backend fourni par l'utilisateur. Une contrainte à connaître :

  • --endpoint est une entrée de confiance. Le validateur rejette les schémas non-http(s) et les URLs avec credentials (user:pass@host), mais accepte loopback, RFC 1918, link-local et les cibles de métadonnées cloud (169.254.169.254). Dans une invocation CLI mono-utilisateur c'est le comportement attendu (environnements de développement locaux, backends joints par redirection de port, etc.). Dans un pipeline CI où la valeur d'endpoint pourrait provenir d'une PR externe ou d'une variable d'environnement non fiable, assainissez la valeur en amont avant d'invoquer le subcommand.

Headers d'authentification

Les deux subcommands acceptent un flag optionnel --auth-header "Name: Value" qui attache un header personnalisé à chaque requête backend. Utilisable pour Bearer tokens, Basic Auth ou headers de clé d'API personnalisés. La valeur parsée est marquée sensitive, donc hyper la masque dans les sorties de debug et l'exclut des tables HPACK HTTP/2, et le subcommand ne journalise jamais la valeur. Exemples :

bash
perf-sentinel jaeger-query --endpoint https://jaeger.prod \
  --service order-svc --lookback 1h \
  --auth-header "Authorization: Bearer ${JAEGER_TOKEN}"

perf-sentinel tempo --endpoint https://tempo.prod \
  --service order-svc --lookback 1h \
  --auth-header "X-API-Key: ${TEMPO_KEY}"

Validation (rejet au parse avec exit code dédié) :

  • Entrée brute sous 8 KiB.
  • Nom et valeur non vides une fois les espaces retirés.
  • Valeur HTTP valide selon RFC 7230 (pas de CR, LF ni ASCII non visible).
  • Nom d'header refusé si : Host, Content-Length, Transfer-Encoding, Connection, Upgrade, TE, Proxy-Connection. Ces headers de framing et d'autorité sont bloqués pour éviter le request smuggling et le cache poisoning via l'expansion d'une variable d'environnement non fiable.

--auth-header-env NAME : alternative ps-safe

Les deux subcommands acceptent aussi --auth-header-env NAME, qui lit la ligne d'header depuis la variable d'environnement nommée au lieu de argv. Cela évite l'exposition ps//proc/<pid>/cmdline. La valeur de la variable doit déjà être au format curl Name: Value. --auth-header et --auth-header-env sont mutuellement exclusifs au niveau clap.

bash
export JAEGER_AUTH="Authorization: Bearer ${JAEGER_TOKEN}"
perf-sentinel jaeger-query --endpoint https://jaeger.prod \
  --service order-svc --lookback 1h \
  --auth-header-env JAEGER_AUTH

Réserves communes aux deux flags :

  • Un seul header par invocation. Si vous avez besoin de Basic Auth plus d'un header de tenant, composez le flag avec le schéma d'auth primaire et gérez le secondaire au niveau du reverse proxy.
  • Passer --auth-header avec un endpoint http:// émet un tracing::warn! car la credential voyagerait en clair. Préférez https:// dès que le backend le permet.

Précision des estimations carbone

Perf Sentinel utilise un modèle proxy I/O → énergie → CO₂ pour estimer l'empreinte carbone des charges de travail analysées. La chaîne comporte trois étapes, chacune introduisant une marge d'erreur :

  1. Opérations I/O → énergie : chaque opération I/O émise par le service émetteur (requête SQL, appel HTTP) est multipliée par une constante fixe ENERGY_PER_IO_OP_KWH de 0.0000001 kWh (~0,1 mWh). Cette valeur n'est pas mesurée : c'est une approximation d'ordre de grandeur. Chaque barreau de l'échelle, le proxy I/O compris, chiffre l'hôte propre du service émetteur pour chaque opération qu'il émet : CPU, mémoire et pile réseau de cet hôte. L'appelé (serveur de base de données, broker, service en aval) n'y figure jamais. L'énergie des hôtes de base de données et de broker n'entre dans un rapport que par une source déclarée ou mesurée : [green.alumet.database], [green.database_static], [green.alumet.broker] ou [green.broker_static]. Les poids par verbe SQL et les paliers HTTP sont des poids relatifs qui classent les familles d'opérations. Leurs ratios viennent de benchmarks de serveurs de base de données, mais ils ne facturent aucun hôte de base de données.
  2. Énergie → CO₂ : l'énergie est multipliée par une intensité carbone réseau par région (gCO₂eq/kWh) issue d'Electricity Maps et Cloud Carbon Footprint (moyennes annuelles 2023-2024), avec un PUE par fournisseur (AWS 1,15, GCP 1,09, Azure 1,17, Generic 1,2). Les trois PUE fournisseurs ne sont pas strictement comparables en périmètre. AWS publie une moyenne flotte mondiale pour l'année calendaire 2024, GCP une moyenne TTM (trailing-twelve-month) sur la flotte mondiale en 2024, et Azure une valeur FY25 (juillet 2024 à juin 2025) pour ses seuls centres de données détenus et exploités en propre (les sites loués et en colocation sont exclus). L'écart de fenêtre est d'environ 12 mois et l'écart de périmètre est de l'ordre de quelques pourcents de la flotte.
  3. Carbone embarqué (M dans SCI v1.0) : émissions de fabrication matérielle amorties à un défaut configurable de 0.001 gCO₂/request. Indépendant de la région.

Incertitude : multiplicative 2×, pas ±50%

Chaque estimation CO₂ est rapportée comme { low, mid, high } où :

low  = mid × 0.5   (moitié du point médian)
high = mid × 2.0   (double du point médian)

C'est un intervalle multiplicatif log-symétrique, pas une fenêtre arithmétique ±50%. La moyenne géométrique de low et high est égale à mid, alors que la moyenne arithmétique ne l'est pas. Ce cadrage 2× reflète l'incertitude d'ordre de grandeur du modèle proxy I/O (ENERGY_PER_IO_OP_KWH est plus approximatif que la moitié). Une fenêtre ±50% symétrique sous-estimerait l'incertitude réelle du modèle. Interprétez les bornes comme "la valeur réelle est dans un facteur 2 de mid, dans un sens ou l'autre".

Les bornes reflètent l'incertitude agrégée du modèle, pas la variance par endpoint.

Cet intervalle est un indicateur directionnel d'incertitude modèle, pas un intervalle de confiance statistique. La valeur réelle sur des charges I/O atypiques (mix SQL + HTTP, chemins très dépendants du cache, moteurs de stockage personnalisés) peut sortir de [low, high]. Utilisez la plage pour jauger la plausibilité d'ordre de grandeur, pas comme borne probabiliste.

Sémantique SCI v1.0 : numérateur vs intensité

Le champ co2.total contient le numérateur SCI v1.0 (E × I) + M, sommé sur toutes les traces analysées (une empreinte, émissions absolues). Le score d'intensité par unité fonctionnelle que la spécification SCI appelle "SCI" est émis à côté, sur co2.sci_per_trace :

co2.sci_per_trace.mid = co2.total.mid / analysis.traces_analyzed

L'unité fonctionnelle R est déclarée sur co2.functional_unit ("trace"). Les deux vues sont conservées car elles répondent à des questions différentes : l'empreinte dimensionne l'impact absolu, l'intensité le normalise par unité de travail. Le champ methodology de chaque CarbonEstimate tague la sémantique :

  • co2.total.methodology = "sci_v1_numerator+transport" : l'empreinte (E × I) + M + T sur les traces analysées. "sci_v1_numerator+transport+hosts" sur une fenêtre du daemon scorée avec une source d'hôte de base de données ou de broker configurée, dont le carbone d'hôte compté dans les totaux rejoint l'empreinte. Les rapports historiques peuvent porter "sci_v1_numerator".
  • co2.sci_per_trace.methodology = "sci_v1_intensity" : l'intensité par R ((E × I) + M) / R, R = 1 trace.
  • co2.avoidable.methodology = "sci_v1_operational_ratio" : operational × (avoidable_io_ops / accounted_io_ops), un ratio global aveugle à la région qui exclut le carbone embarqué. "sci_v1_operational_ratio+hosts" sur ces mêmes fenêtres, qui ajoutent chaque terme d'hôte compté dans les totaux multiplié par son propre ratio de gaspillage SQL ou messaging.

Positionnement : compteur de gaspillage directionnel

Perf Sentinel est un compteur de gaspillage directionnel conçu pour :

  • Détecter les anti-patterns de performance (N+1, requêtes redondantes, fanout) et quantifier leur impact carbone relatif.
  • Comparer les exécutions avant/après optimisation pour valider qu'un correctif réduit les I/O.
  • Détecter les régressions carbone en CI comme garde-fou.

Ce n'est PAS un outil de comptabilité carbone réglementaire. N'utilisez PAS Perf Sentinel pour :

  • Le reporting CSRD (Corporate Sustainability Reporting Directive).
  • Les déclarations GHG Protocol Scope 3.
  • Des documents de conformité à valeur d'audit.
  • Comparer des valeurs CO₂ absolues entre infrastructures différentes (le modèle suppose un profil serveur uniforme et moyen).
  • Remplacer des données d'énergie mesurées (RAPL, Scaphandre, wattmètres in-process).

Ce qui fonctionne

Cas d'usageFiabilité
Détecter le gaspillage (N+1, fanout, redondant)✅ comptage déterministe
Comparer les exécutions (baseline vs. correctif)✅ deltas relatifs significatifs
Classer les endpoints par impact relatif✅ au sein d'un déploiement unique
Garde-fous de régression carbone en CI✅ via [thresholds] io_waste_ratio_max
CO₂ absolu pour rapports de conformité❌ incertitude multiplicative 2×
Comparaison cross-infrastructure❌ profil serveur uniforme supposé
Remplacer l'énergie mesurée❌ proxy uniquement

Scoring multi-région

Quand les spans OTel portent l'attribut de ressource cloud.region, Perf Sentinel répartit automatiquement les ops I/O par région et applique le bon coefficient d'intensité réseau. La chaîne de repli est :

  1. event.cloud_region depuis l'attribut OTel.
  2. [green.service_regions] mapping config par service.
  3. [green] default_region.

Les ops I/O sans région résolvable atterrissent dans un bucket synthétique "unknown" et contribuent à zéro CO₂ opérationnel (un tracing::warn! est émis). Le carbone embarqué est tout de même émis car les émissions matérielles sont indépendantes de la région.

Voir 05 · GreenOps & carbon pour la méthodologie complète, la formule et les notes d'alignement SCI v1.0.

Profils carbone horaires

Des profils d'intensité carbone horaire UTC sont embarqués pour plus de 30 régions cloud couvrant tous les principaux fournisseurs et zones géographiques. Quatre régions (FR, DE, GB, US-East) ont des profils mois x heure (288 valeurs chacun) qui capturent la variation saisonnière. Les autres régions ont des profils annuels plats (24 valeurs, même forme toute l'année).

Régions mois x heure (12 mois x 24 heures) :

  • France (eu-west-3) : baseload nucléaire avec pic de gaz hivernal. Plus haute intensité en hiver, plus basse en été.
  • Allemagne (eu-central-1) : charbon + renouvelables. Forte variance saisonnière : utilisation du charbon significativement accrue en hiver.
  • Royaume-Uni (eu-west-2) : éolien + gaz. L'hiver a plus de chauffage au gaz, l'été plus d'éolien.
  • US-East (us-east-1) : gaz + charbon. La climatisation estivale et le chauffage hivernal poussent l'intensité au-dessus du printemps/automne.

Régions horaires annuelles plates (profil 24h, même toute l'année) :

  • Europe (ENTSO-E) : Irlande (eu-west-1), Pays-Bas (eu-west-4), Suède (eu-north-1), Belgique (europe-west1), Finlande (europe-north1), Italie (eu-south-1), Espagne (europe-southwest1), Pologne (europe-central2), Norvège (europe-north2).
  • Amériques (EIA / IESO / ONS) : US Ohio (us-east-2), US N. Californie (us-west-1), US Oregon (us-west-2), Canada Québec (ca-central-1), Brésil (sa-east-1).
  • Asie-Pacifique (best-effort) : Japon (ap-northeast-1), Singapour (ap-southeast-1), Inde (ap-south-1), Australie (ap-southeast-2).

Les alias de codes pays (fr, de, gb, ie, se, no, jp, br, etc.) et synonymes fournisseurs cloud (westeurope, northeurope, uksouth, francecentral, gra11, fr-par, waw1, nl-ams, bhs5, outscale-eu-west-2, etc.) sont pris en charge et résolvent vers le même profil. Une région sans alias retombe sur la valeur annuelle plate, c'est pourquoi chaque clé ajoutée a besoin de sa propre ligne d'alias au lieu d'en hériter de son pays.

Quand [green] use_hourly_profiles = true (le défaut), l'étape de scoring utilise l'intensité spécifique à l'heure (et au mois quand disponible) pour chaque span basée sur son horodatage UTC. Les régions sans profil utilisent toujours la valeur annuelle plate. Les rapports sont tagués model = "io_proxy_v3" (mois x heure), "io_proxy_v2" (horaire annuel plat) ou "io_proxy_v1" (annuel) et chaque ligne de ventilation par région porte un champ intensity_source ("annual", "hourly" ou "monthly_hourly").

Ce que ça fait et ne fait pas. Le chemin horaire capture la variance au fil de la journée (un N+1 à 3h du matin en France coûte moins qu'un N+1 à 19h). Les profils mois x heure capturent aussi la variance saisonnière pour les 4 régions listées. Il ne capture PAS :

  • Les fluctuations liées à la météo : les valeurs embarquées sont des moyennes typiques, pas des données temps-réel. Une journée calme et sans vent au Royaume-Uni produira plus de carbone que ne le suggère le profil.
  • Les données en temps réel : les profils embarqués sont statiques. Pour l'intensité carbone en temps réel (marquée intensity_source = "real_time" dans les rapports), activer l'intégration opt-in [green.electricity_maps] en mode daemon, voir Configuration.

Profils estimés. Les profils Asie-Pacifique et Brésil sont estimés à partir de la composition du mix de combustibles plutôt que de données horaires de génération. Ils sont annotés comme tels dans le code source. Les formes journalières sont des approximations fondées sur le mix de combustibles connu (par exemple, les réseaux dominés par le gaz sont presque plats, les réseaux très charbonnés ont de légers pics en soirée).

Exigences d'horodatage. Perf Sentinel parse les horodatages en UTC et exige la forme canonique ISO 8601 YYYY-MM-DDTHH:MM:SS[.fff]Z (Z final) ou la variante avec espace. Les chaînes avec offset non-UTC (+02:00, -05:00) sont rejetées plutôt que silencieusement décalées, car la table carbone est ancrée UTC et un traitement naïf des offsets fausserait systématiquement l'estimation. Les spans dont l'horodatage ne peut pas être parsé retombent sur l'intensité annuelle plate.

Amélioration de précision (approximative). Par rapport au modèle plat-annuel, les profils horaires réduisent la composante temps-de-jour du budget d'incertitude de ~±50% à ~±20% pour les 4 régions listées uniquement. L'intervalle d'incertitude multiplicative 2× global sur l'estimation CO₂ est inchangé, car la constante proxy énergie-par-op reste la source d'erreur dominante.

Pour figer les rapports sur le modèle annuel plat (ex. pour comparer des runs historiques sans le décalage horaire), mettre [green] use_hourly_profiles = false dans la config.

Profil horaire Allemagne (eu-central-1) : divergence résolue en 0.8.7

Jusqu'en 0.8.6 le profil horaire Allemagne portait une moyenne arithmétique de ~431 g/kWh face à une valeur annuelle plate embarquée de 338, un écart de ~28% que la documentation décrivait comme "des données récentes plus élevées". Les sources primaires montrent l'inverse. Le réseau allemand s'est décarboné sur 2023-2025 (Electricity Maps consommation : 475 en 2022, 379 en 2023, 341 en 2024), donc le niveau du profil horaire était le côté périmé, figé au niveau de la crise charbon de 2022, alors que la valeur annuelle 338 était à jour. Tel que livré avant 0.8.7, activer les profils horaires gonflait le CO₂ de Francfort d'environ 27% par rapport aux données primaires actuelles.

Depuis 0.8.7 le profil est recalibré (forme préservée, niveau normalisé sur la moyenne Electricity Maps 2024 de ~341), donc les chemins horaire et annuel plat concordent dans les ±5% comme toutes les autres régions profilées.

Ce que ça signifie pour vos rapports :

  • Les rapports avec default_region = "eu-central-1" (ou des spans portant cloud.region = eu-central-1) et le défaut use_hourly_profiles = true affichent des chiffres CO₂ environ 21% plus bas qu'en 0.8.6.
  • Aucune règle de [thresholds] ne lit un chiffre CO₂ : les règles comptent des findings, comparent le ratio de gaspillage I/O ou vérifient la part de spans analysables. Un quality gate CI n'est donc pas touché. Seul un seuil que vous avez fixé vous-même sur une valeur de CO₂ serait à recalibrer.

Un test de régression (de_flat_annual_numerical_regression) épingle la valeur annuelle plate, et l'invariant ±5% profil-contre-annuel est désormais appliqué à toutes les régions sans exception.

Ce que couvre une attribution purement logicielle

Perf Sentinel est un outil d'attribution purement logiciel. Cette classe regroupe les lecteurs RAPL (intel-rapl via Powercap) et les estimateurs basés sur un modèle qui dérivent l'énergie de l'utilisation CPU (Cloud SPECpower, coefficients SPECpower épinglés par SKU). Sur un serveur typique, ni l'un ni l'autre ne voit la puissance totale prise au wattmètre. RAPL rapporte les packages CPU et DRAM et manque le contrôleur de stockage, les SSD, les cartes réseau, les ventilateurs, le BMC, ainsi que les pertes de conversion de l'alimentation. Les estimateurs basés sur un modèle héritent du même périmètre par construction, puisque leurs coefficients sont calibrés sur la puissance CPU et DRAM.

Les mesures indépendantes publiées varient selon le matériel et la charge, mais l'ordre de grandeur est cohérent : sur les CPU serveurs Intel courants, RAPL capte environ la moitié à deux tiers de la puissance prise au wattmètre, le reste étant la périphérie. Perf Sentinel appartient à la même classe et se situe dans la même plage. Pour l'énergie totale serveur sur un SKU connu, il faut le coupler à un wattmètre externe (PDU SNMP, smart plug) ou à une lecture matérielle. Pour l'énergie compute et DRAM attribuable par trace, la discussion de précision se trouve dans les sections Limites de précision Scaphandre et Limites de précision du cloud SPECpower ci-dessous.

Quand vous lisez des benchmarks qui comparent ces outils à un wattmètre externe, gardez deux grandeurs séparées. Premièrement, la périphérie qu'aucun signal purement logiciel ne peut couvrir. Deuxièmement, à quel point un outil donné attribue correctement la fraction qu'il voit à un container, un processus ou un span. Seule la deuxième est une propriété de l'outil. La première est une propriété du signal.

Limites de précision Alumet

Perf Sentinel intègre en opt-in Alumet (INRIA/LIG, EUPL-1.2), scrapé via son plugin de sortie prometheus-exporter. alumet_rapl est en tête de la chaîne de précédence de l'énergie mesurée.

Pourquoi il surclasse Scaphandre. Les deux lisent RAPL. L'échantillonnage d'Alumet est mesurablement moins erroné, comme le caractérisent ses propres auteurs dans Dissecting the software-based measurement of CPU energy consumption (Raffin et al.), et il attribue par cgroup plutôt que par processus, ce qui colle mieux aux charges conteneurisées. Être classé premier est une affirmation sur la fidélité de l'attribution, pas sur la couverture : comme Scaphandre, RAPL ne voit que le CPU et la DRAM, soit environ la moitié à deux tiers de la puissance prise au wattmètre. Pour l'énergie totale du serveur, voir Limites de précision Redfish BMC.

Le mode d'échec par désaccord d'intervalle. Le prometheus-exporter d'Alumet publie chaque mesure comme une jauge Prometheus contenant la dernière valeur flushée, et rapl_consumed_energy est un CounterDiff : les joules consommés pendant un poll_interval de la source. Ce n'est ni un compteur cumulatif (comme Kepler), ni une puissance (comme Scaphandre). Deux conséquences :

  • Sommer les relevés bruts entre scrapes est faux dans les deux sens. Scraper plus vite qu'Alumet ne flushe compte deux fois la même valeur, scraper moins vite perd des intervalles entiers. Perf Sentinel ne somme donc jamais. Il divise par energy_interval_secs pour retrouver des watts et intègre sur sa propre fenêtre de scrape, exactement comme il le fait pour la jauge de puissance de Scaphandre.
  • energy_interval_secs ne peut pas être vérifié sur le fil. L'intervalle n'apparaît nulle part dans l'exposition. Si la valeur déclarée s'écarte du poll_interval côté Alumet, toutes les valeurs d'énergie et de carbone des services mappés sont mises à une échelle linéairement fausse, en silence. Déclarer 1.0 alors qu'Alumet échantillonne à 5s surestime l'énergie d'un facteur 5, sans avertissement, sans scrape en échec, et avec une étiquette de provenance measured qui semble faire autorité. Pour une série RAPL, c'est le plus gros risque de justesse de l'intégration. Revérifiez les deux fichiers ensemble dès que l'un des deux change. Le daemon affiche la valeur utilisée dans la ligne de log Alumet scraper started, la faute est donc au moins visible au démarrage.

L'hypothèse de stationnarité est la même que celle que porte Scaphandre : l'intervalle échantillonné est pris comme représentatif de toute la fenêtre de scrape. Le relevé d'Alumet est une moyenne sur son poll_interval plutôt qu'un échantillon instantané, ce qui est plutôt le mieux comporté des deux.

Le mode d'échec par désaccord d'unité. Le scraper prend chaque valeur pour des joules et ne peut pas le vérifier. En v0.9.5, l'exporteur n'écrit ni suffixe d'unité ni ligne # UNIT pour une métrique qui a une unité, et energy-attribution déclare des joules quoi que calcule sa formule. Une série dans une autre unité est mise à l'échelle tout aussi silencieusement, et plus fortement : une série en millijoules comme nvml_energy_consumption ou grace_energy_consumption se lit 1000 fois trop haut, et une jauge de puissance comme l'input_power de Jetson (en milliwatts) n'est pas du tout une énergie. D'où la règle de Configuration qui réserve l'intégration aux séries RAPL. L'avertissement de démarrage sur les noms non RAPL connus et les suffixes d'unité autres que le joule est le seul filet à l'exécution.

L'attribution demande une composition de plugins. rapl seul mesure la machine, sans notion de charge de travail. procfs n'identifie les consommateurs que par PID, ce qui est inutilisable pour un mapping de service stable. L'attribution par service demande rapl + k8s + energy-attribution, et la métrique résultante porte le nom d'une formule choisie par l'opérateur. C'est pourquoi metric_name et label_key sont des champs de configuration obligatoires sans défaut, voir Configuration.

Une valeur de label partagée par plusieurs services est répartie. Plusieurs services associés à une même valeur de label_key lisent un seul chiffre d'énergie, y compris tous les services associés à package avec label_key = "domain". Il est compté une fois et réparti entre ces services selon les opérations que chacun a émises dans la fenêtre de scrape, donc une lecture de domaine à l'échelle de l'hôte se répartit entre les services qui ont fait des I/O, pas entre les processus qui ont tiré la puissance.

Version amont. Alumet est pré-1.0 (v0.9.5 au moment de l'écriture) et son job CI de conformité de fil couvre l'exporteur (procfs) mais pas RAPL : les runners GitHub n'exposent aucun arbre powercap. Les noms de métriques et la configuration des plugins peuvent changer d'une version à l'autre. Le filet à l'exécution est l'avertissement unique zéro-échantillon, qui se déclenche après trois ticks HTTP 200 consécutifs sans échantillon correspondant et nomme les causes probables. Traitez une montée de version d'Alumet comme une raison de relancer curl <endpoint> | grep -i energy et de comparer avec metric_name.

Prérequis de plateforme. Linux, et la source rapl, qui demande un x86_64 Intel ou AMD avec accès RAPL (perf-events ou un /sys/devices/virtual/powercap/intel-rapl lisible), donc les mêmes contraintes bare-metal et de passthrough RAPL que Scaphandre s'appliquent. ARM n'a pas de RAPL. Là, les sources Alumet qui lisent des capteurs intégrés visent du matériel Nvidia (nvidia-jetson, grace-hopper), et cette intégration ne les prend pas en charge : elles rapportent une puissance en milliwatts ou en microwatts, ou une énergie en millijoules, pas des joules par intervalle. L'étiquette de modèle alumet_rapl est appliquée à toute lecture Alumet, quel que soit le plugin source qui l'a produite. Hors de l'avertissement de démarrage sur les noms non RAPL connus, Perf Sentinel ne peut pas distinguer une série RAPL d'une autre, le nom de métrique étant choisi par l'opérateur. Une série non RAPL porterait donc alumet_rapl dans le rapport, dans energy_source_models et dans toute divulgation publiée, en plus d'une éventuelle erreur d'unité.

Le gaspillage de base de données est une borne basse quand il est mesuré, une part côté application quand il est estimé, avec un ratio par comptage. green_summary.database_waste multiplie l'énergie de la base par avoidable_sql_io_ops / total_sql_io_ops. Avec [green.alumet.database] déclaré et une lecture reçue, cette énergie est mesurée sur le cgroup de la base (model = "alumet_rapl"). Quand aucune source de base n'est configurée (ni [green.alumet.database] ni [green.database_static]), le chiffre est la part côté application des spans SQL de la fenêtre, leur énergie telle que le scoring l'a résolue : sources mesurées par service quand elles existent, proxy I/O avec son encadrement 2x sinon (model = "estimated"). Il ne contient aucune énergie d'hôte de base de données, et son carbone utilise la région du service émetteur plutôt que celle de la base. Sa précision suit donc le modèle d'énergie du rapport plutôt que les bornes mesurées ci-dessous. Une base mesurée sans lecture reçue n'émet rien. Son énergie se reporte sur la prochaine fenêtre livrée. Quand la série Alumet devient périmée, un plafond [green.database_static], s'il est déclaré, facture la panne à la place, sous les bornes de Limites de précision des bases déclarées. Quatre bornes à garder en tête. D'abord, sur le chemin mesuré, le terme d'énergie est la seule part CPU du package. La formule d'attribution par défaut d'Alumet ne couvre ni la DRAM ni les I/O disque, et une base de données consomme beaucoup des deux. Le chiffre sous-estime donc la consommation réelle (la puissance idle n'est pas attribuée par Alumet, ce qui est le comportement souhaité pour un ratio de gaspillage). Ensuite, le ratio compte les opérations sans pondérer leur coût : les benchmarks énergétiques de SGBD mesurent jusqu'à 60 % d'écart de puissance CPU entre opérateurs à utilisation égale (Tsirogiannis et al., SIGMOD 2010), donc un mélange de SELECT ponctuels évitables et peu coûteux face à des écritures lourdes légitimes biaise la répartition. Troisièmement, le risque de désaccord d'intervalle ci-dessus s'applique aussi à ce chiffre. Enfin, quand des lots d'analyse sont délestés sous charge, leur énergie se reporte sur la prochaine fenêtre scorée et se multiplie par le ratio de cette fenêtre-là. Le gaspillage par fenêtre est donc approximatif sous délestage. Le total d'énergie entre fenêtres archivées est exact sauf pour les fenêtres que l'écrivain d'archive a lui-même perdues (canal plein, écrivain mort, sérialisation ou écriture échouée, toutes journalisées et comptées sur perf_sentinel_archive_windows_dropped_total). Pour toutes ces raisons, lisez le gaspillage comme directionnel. L'énergie mesurée est comptée dans energy_kwh et co2.total comme un terme d'hôte dès que son carbone est converti, ces bornes se reportent donc dans les totaux, où le terme de base de données sous-estime son hôte. La variante estimée y est déjà comprise. La divulgation publie le chiffre dans le bloc séparé aggregate.database_waste (schéma v1.4), une ventilation jamais ajoutée par-dessus un total. Il suppose aussi que le cgroup déclaré sert tout le trafic SQL vu par le daemon. L'ingestion des spans ne garde aucune identité d'appelé, le ratio SQL mélange donc tous les spans SQL de la fenêtre, quelle que soit la base atteinte. Une seule déclaration de base par daemon : déclarez la base qui sert votre trafic SQL et gardez les déploiements multi-bases hors de ce chiffre sauf si les bases sont homogènes.

Limites de précision des bases déclarées

[green.database_static] avec placement = "dedicated", nodes et instance_type facture nodes × P_max sur le temps écoulé, avec l'étiquette database_specpower. C'est une déclaration de l'opérateur sur des hôtes provisionnés, pas une lecture, et son erreur va dans des sens connus.

  • P_max surestime une base peu chargée. Le plafond est la puissance de chaque vCPU déclaré à 100 % de CPU. Une base qui tourne à 10 % consomme bien moins, le chiffre, et le terme de base de données qu'il ajoute à energy_kwh et co2.total, est donc une borne haute du calcul la plus grande partie de la journée. Il ne bouge pas quand un N+1 est corrigé, seul le ratio SQL bouge. Un écart de plus d'une heure entre deux lots scorés facture une heure, une longue période calme est donc sous-comptée.
  • nodes compte les lecteurs et les standbys provisionnés. Chaque hôte provisionné consomme, qu'il serve des requêtes ou non : l'instance d'écriture, chaque lecteur, chaque standby Multi-AZ. L'opérateur les déclare, le daemon ne les voit pas, et un cluster redimensionné sans mise à jour de nodes est facturé à son ancienne taille.
  • Le stockage, les IOPS et le réseau managés ne sont pas comptés. La table SPECpower couvre le CPU et la carte mère. La couche de stockage derrière une base managée, ses IOPS provisionnées et le réseau entre ses hôtes consomment une énergie que le plafond laisse de côté, et sur une base limitée par le stockage ils peuvent dépasser le calcul.
  • Les lectures applicatives ne doivent pas contenir l'hôte de la base. Le plafond s'ajoute à l'énergie applicative : un hôte de base qu'une requête CPU de [green.cloud.services] lit aussi compte deux fois. Un service laissé sur la requête CPU par défaut de son fournisseur lit la première série de la flotte, qui peut être l'hôte de la base, et le chargement émet un avertissement : donnez à chacun de ces services une cpu_query filtrée sur ses propres nœuds. Une base sur un nœud applicatif se déclare plutôt placement = "shared", ce qui retire sa part de la lecture du nœud, voir Ce que le cloud SPECpower ne fait PAS.
  • Un seul daemon déclare la base. Chaque daemon qui déclare la section facture le cluster entier pour chaque seconde où il tourne, quelle que soit la part du trafic SQL qu'il voit : des daemons shardés qui la déclarent tous comptent donc la base une fois par daemon. Le ratio SQL mélange tous les spans SQL de la fenêtre, quelle que soit la base atteinte, comme pour le chiffre mesuré.

À côté de [green.alumet.database], le plafond ne facture que les périodes où la série Alumet est périmée : ces bornes s'appliquent à ces périodes, et les bornes mesurées au reste.

Avec une cpu_query, les hôtes sont plutôt chiffrés sur leur CPU, nodes × (idle + (P_max - idle) × cpu / 100), avec l'étiquette cloud_specpower, et le plafond ne facture que les périodes sans aucune lecture depuis trois intervalles de scrape. Ce chiffre suit la charge : la première borne ne surestime plus une base calme, et les autres tiennent. Il ajoute ses propres bornes :

  • Les métriques CPU managées sont en retard sur les hôtes. La supervision cloud publie l'utilisation CPU une à cinq minutes après coup, et un exporteur ajoute son propre délai : chaque intervalle est donc facturé au CPU de quelques minutes plus tôt. Les totaux sur une période sont proches, une fenêtre isolée ne l'est pas.
  • La requête est la moyenne de l'opérateur. Elle doit renvoyer une seule série, le CPU moyen des hôtes déclarés. Une requête qui oublie un lecteur ou un standby, ou qui moyenne des hôtes d'autres bases, chiffre le cluster sur le mauvais CPU, et le daemon ne peut pas le voir. Une réponse à plusieurs séries ne facture rien.
  • Des hôtes de tailles différentes sont des groupes distincts. Une seule requête qui moyenne un gros writer et un petit reader les chiffre tous deux à la même charge et sur une seule classe. [[green.database_static.hosts]] donne à chaque groupe sa classe et sa requête, et chaque groupe se replie sur son propre plafond, si bien qu'une fenêtre où un groupe a facturé son plafond porte database_specpower pour tout le chiffre de la base. Le ratio SQL mélange toujours tous les spans SQL de la fenêtre : avec un groupe par base de tenant, la répartition du gaspillage entre les bases reste un mélange.
  • idle est la puissance au repos SPECpower de la classe. Un hôte à 0 % de CPU facture encore nodes × idle, le plancher provisionné, ce qui est voulu, et c'est une valeur de table, pas le repos mesuré de l'hôte.

Limites de précision Scaphandre

Perf Sentinel embarque une intégration opt-in avec Scaphandre pour la mesure énergétique par processus via les compteurs Intel RAPL. Quand [green.scaphandre] est configuré, le daemon watch scrape l'endpoint Prometheus Scaphandre toutes les quelques secondes et utilise les lectures de puissance mesurées pour remplacer la constante proxy ENERGY_PER_IO_OP_KWH fixe pour chaque service mappé.

Préférez Alumet pour les nouveaux déploiements. Limites de précision Alumet ci-dessus explique pourquoi alumet_rapl surclasse scaphandre_rapl : même source RAPL, échantillonnage moins erroné comme le caractérise le papier de Raffin et al., et attribution par cgroup. Le rythme amont va dans le même sens : la dernière version de Scaphandre, la v1.0.3 (juillet 2026), est arrivée 17 mois après la v1.0.2 et ne joint aucun binaire à sa release GitHub, et l'issue de suivi ARM ci-dessous est ouverte depuis 2020. L'intégration Scaphandre reste prise en charge pour les déploiements existants.

Version upstream. Le parser ne dépend pas de la version : il consomme l'exposition Prometheus standard de scaph_process_power_consumption_microwatts avec les labels exe et cmdline, stable à travers les releases upstream. Le job CI wire-conformance (.github/workflows/upstream-wire-conformance.yml) épingle par empreinte l'image conteneur amont de Scaphandre v1.0.3 comme version de référence validée. Ce binaire s'annonce en 1.0.2, parce que le tag v1.0.3 a gardé version = "1.0.2" dans son Cargo.toml. Les autres releases récentes sont attendues comme fonctionnelles. Un renommage upstream de la métrique ou des labels déclencherait au runtime le filet warn-once "zero-sample" et l'assertion wire-conformance en CI.

Exigences plateforme. Scaphandre fonctionne sur :

  • Linux, dans le périmètre que valide Perf Sentinel. L'amont prend aussi en charge Windows via son capteur MsrRAPL et un pilote dédié, mais la v1.0.3 a arrêté les chaînes de test et de paquets Windows, et Perf Sentinel n'a jamais validé cette voie. Pas de macOS, pas de BSD.
  • CPU x86_64 Intel ou AMD avec prise en charge de RAPL : la plupart des puces serveur et desktop récentes, mais notamment PAS ARM64. Apple Silicon, Ampere, Graviton et les instances cloud ARM similaires sortent de ce que l'amont a validé.
  • Bare metal ou VMs avec passthrough RAPL. La plupart des VMs cloud (AWS EC2, GCP GCE, Azure VMs) n'exposent pas les compteurs RAPL aux OS invités. Les pods Kubernetes s'exécutant sur des nœuds bare-metal peuvent accéder à RAPL si l'hôte expose /sys/class/powercap/intel-rapl/ dans le conteneur (nécessite accès privilégié ou mount explicite).

Pourquoi la branche Scaphandre ne couvre pas ARM64. L'amont Scaphandre suit la prise en charge d'ARM dans l'issue #35 depuis 2020 sans implémentation, et son tableau de compatibilité marque toujours le capteur PowercapRAPL sur ARM bare metal comme "We don't know yet". RAPL est une interface Intel reprise par AMD à partir du noyau 5.11, et le capteur la lit via /sys/class/powercap/intel-rapl. Le capteur par estimation qui couvrirait toute architecture est une fonctionnalité prévue, suivie dans l'issue #25, toujours ouverte et mise à jour pour la dernière fois en novembre 2022, et la dernière discussion sur l'estimation dans l'issue #35 date de novembre 2023. Parmi les sources d'énergie mesurée que lit Perf Sentinel, Limites de précision Redfish BMC (puissance du châssis lue sur le BMC d'un serveur physique) est la source qui, en pratique, ne dépend pas de l'architecture du CPU. Elle se place avant cloud_specpower dans la chaîne de priorité, donc les charges ARM sur de tels hôtes obtiennent un vrai signal avant que la pile ne retombe sur les coefficients CCF Graviton/Cobalt (±40%) puis sur le proxy I/O. Kepler 0.10 et suivants lisent RAPL, ou des capteurs hwmon en repli expérimental, et ne publient aucune image arm64 à la v0.12.0, voir Limites de précision Kepler.

Sur les plateformes non prises en charge, la section [green.scaphandre] est parsée et le scraper est lancé, mais il échouera à trouver l'endpoint et retombera silencieusement sur le modèle proxy. Une seule ligne de log au niveau warn est émise au premier échec pour que les opérateurs remarquent la mauvaise configuration.

Ce que Scaphandre améliore. L'intégration remplace le coefficient proxy fixe (0,1 mWh par op I/O) par une valeur mesurée au niveau service dérivée de la consommation réelle du processus mappé sur la fenêtre de scrape. Formule :

energy_per_op_kwh = (process_power_watts × scrape_interval_secs) / ops_in_window / 3_600_000

Ce qui capture :

  • La puissance processus réelle (pas une approximation moyenne).
  • Les différences entre services : Java vs .NET vs Node vs Go auront des empreintes énergétiques différentes même pour des charges I/O similaires.
  • La variance de charge dans le temps : un service idle et un service en charge obtiennent des coefficients différents pendant que le daemon tourne.

Les rapports où au moins un service a utilisé un coefficient mesuré sont tagués model = "scaphandre_rapl". Chaîne de priorité complète : electricity_maps_api > grid_operator_api > alumet_rapl > scaphandre_rapl > kepler_ebpf > redfish_bmc > cloud_specpower > io_proxy_v3 > io_proxy_v2 > io_proxy_v1. Quand des facteurs de calibration sont actifs sur les modèles proxy, le suffixe +cal est ajouté (ex. io_proxy_v2+cal). Le suffixe +cal ne s'applique jamais à un tag mesuré, le multiplicateur de calibration cible le coefficient proxy et n'a plus de sens dès qu'une lecture mesurée le remplace.

Ce que Scaphandre ne fait PAS. Scaphandre donne des coefficients par service, pas d'attribution par finding. Spécifiquement :

  1. RAPL est au niveau processus, pas au niveau span. La métrique scaph_process_power_consumption_microwatts{exe="java"} rapporte la consommation totale du processus java. Elle ne peut pas distinguer deux findings N+1 concurrents tournant dans le même processus au même moment : ils partagent le coefficient par construction.
  2. L'intervalle de scrape n'est PAS le goulot de précision. Une fenêtre de 5 secondes moyenne la puissance sur 5 secondes. Passer à 1 seconde ne donnerait pas de précision par finding parce que RAPL lui-même moyenne à la granularité du pas Scaphandre de 2s. Le plancher de précision réel est "un coefficient par (service, fenêtre_scrape)".
  3. Les services qui sélectionnent un même processus se partagent sa puissance. Si votre architecture fait tourner plusieurs services logiques dans la même JVM, la lecture exe="java" de Scaphandre les couvre tous ensemble. Quand plusieurs règles de process_map sélectionnent ce même processus, sa puissance est comptée une fois et répartie entre ces services selon les opérations que chacun a émises dans la fenêtre de scrape. La répartition suit le nombre d'opérations, pas le CPU que chaque service a consommé dans le processus.
  4. Bruit de l'ordonnanceur OS. L'attribution de puissance par processus via process_cpu_time / total_cpu_time est intrinsèquement bruitée sous charges mixtes.

Modèle mental correct. Scaphandre vous donne un coefficient par opération dynamique, mesuré, au niveau du service au lieu d'une constante proxy fixe et globale. Il améliore la couche d'attribution énergétique de la pile d'estimation carbone, mais ne fait pas de Perf Sentinel un outil de comptabilité carbone de niveau réglementaire. L'intervalle d'incertitude multiplicatif 2× s'applique toujours.

Gestion de la fraîcheur. Le daemon jette les entrées plus anciennes que 3× l'intervalle de scrape lors de la construction du snapshot par tick. Un scraper bloqué ou un service qui cesse d'émettre des événements retombera silencieusement sur le modèle proxy après ~3 intervalles de scrape. La jauge Prometheus perf_sentinel_scaphandre_last_scrape_age_seconds permet aux opérateurs de configurer des alertes Grafana sur la santé du scraper.

Mode batch. Le mode batch analyze ne lance jamais le scraper et n'utilise jamais les données Scaphandre. Même si [green.scaphandre] est présent dans la config, la commande analyze l'ignore entièrement et utilise toujours le modèle proxy. Seul le daemon watch intègre Scaphandre.

Limites de précision Kepler

Perf Sentinel embarque une intégration opt-in pour Kepler (projet CNCF sandbox) qui mesure l'énergie par conteneur ou par processus. Perf Sentinel lit Kepler 0.10 et suivants. Depuis cette réécriture, Kepler lit RAPL via sysfs (powercap), avec hwmon en repli expérimental depuis la v0.12.0, et n'a besoin que d'un accès en lecture à /proc et /sys de l'hôte (README amont). Quand [green.kepler] est configuré, le daemon watch scrape l'endpoint Prometheus /metrics de Kepler, calcule le delta de joules par service par rapport au scrape précédent, et publie un coefficient mesuré par opération tagué model = "kepler_ebpf".

kepler_ebpf est un nom historique. Le tag précède la réécriture : depuis Kepler 0.10, seule version que lit Perf Sentinel, la source est RAPL (sysfs/powercap, hwmon expérimental), pas eBPF. Le tag est conservé pour que les rapports, les publications périodiques et les tableaux de bord qui s'appuient dessus continuent de fonctionner.

Exigences plateforme.

  • Linux avec un compteur de puissance lisible. À la v0.12.0, Kepler retient le premier backend de sa liste cpu.preferredMeters qui expose des zones, rapl puis hwmon par défaut, et s'arrête si aucun n'en expose (référence de configuration). Une VM cloud sans passthrough RAPL n'obtient donc aucune lecture : le modèle de puissance amont pour les VM n'est encore qu'une proposition au stade de brouillon. La cible est le bare metal, instances cloud *.metal comprises.
  • x86_64 en pratique. Kepler v0.12.0 livre une archive linux-amd64 et une image amd64 seule sur quay.io. Cette version a ajouté la compilation ARM64, mais aucune image arm64 n'est encore publiée. L'issue #1556 (prise en charge ARM pour Graviton) a été fermée en juillet 2024, avant la réécriture, et concerne l'ancien Kepler eBPF.
  • Kubernetes est facultatif. --kube.enable vaut false par défaut, et les séries par conteneur et par processus sont exportées sans lui (pod_id reste vide).
  • Kepler installé et exposant /metrics. Les déploiements production exécutent en général Kepler comme DaemonSet Kubernetes, un pod par nœud. Cette version effectue un GET direct, donc pointez vers l'exporteur local au nœud ou vers un endpoint de fédération/proxy qui expose directement les séries Kepler agrégées. Un Service ClusterIP devant le DaemonSet envoie chaque scrape vers un nœud arbitraire. L'endpoint /metrics d'un serveur Prometheus expose uniquement ses métriques internes. Le mode PromQL natif est réservé à une version ultérieure.

Ce que Kepler attribue. Kepler mesure la puissance du nœud par zone et attribue à chaque charge une part de la puissance active proportionnelle à son temps CPU, Workload Power = (Workload CPU Time Δ / Node CPU Time Δ) × Active Power (guide d'attribution de puissance). La puissance au repos n'est pas attribuée aux charges. Le coefficient repose donc sur les mêmes compteurs RAPL qu'Alumet et Scaphandre, avec une répartition au temps CPU par-dessus.

Ce que Kepler améliore vs le proxy. Même forme que Scaphandre : remplace la constante fixe ENERGY_PER_IO_OP_KWH par un coefficient mesuré par service, dérivé du compteur de joules de Kepler et du delta d'opérations par service de la fenêtre de scrape courante. La lecture circule dans la chaîne de priorité comme kepler_ebpf, entre scaphandre_rapl et cloud_specpower (CCF ±40%).

Ce que Kepler ne fait PAS.

  1. Granularité conteneur / processus, pas d'attribution par-finding. Deux findings N+1 dans le même conteneur pendant la même fenêtre de scrape partagent le même coefficient par construction.
  2. Puissance active seulement. La puissance au repos reste au nœud, donc le coefficient Kepler d'un service se situe sous sa part de l'énergie que tire l'hôte. Considérer les lectures Kepler comme un signal plus fort que le proxy, non comme un substitut à un wattmètre externe.
  3. Package CPU seul par défaut. zone = "package" couvre le socket, et la zone dram se situe en dehors. Prévoir une perte de périphérie en plus de la mise en garde habituelle "RAPL capte environ la moitié à deux tiers de la prise murale".
  4. Les services d'une même valeur de label se partagent ses joules. Les services associés au même container_name (ou à la même valeur comm avec metric_kind = "process") lisent un seul compteur : son delta de joules est compté une fois et réparti entre eux selon les opérations que chacun a émises dans la fenêtre de scrape. Associez chaque service à sa propre valeur quand ils tournent dans des conteneurs distincts, pour que chacun ait sa propre lecture.

Une zone, pas une somme de zones. Kepler exporte un compteur par zone, et les zones se recouvrent : package contient déjà core et uncore (documentation amont des métriques). Perf Sentinel ne lit que la zone fixée par [green.kepler] zone, package par défaut, et garde les lignes sans label zone. Les versions antérieures sommaient toutes les zones et surestimaient l'énergie et le carbone issus de Kepler, les chiffres d'avant et d'après ce correctif ne sont donc pas comparables. Restreindre Kepler lui-même avec son réglage rapl.zones fonctionne aussi, mais cela change les séries que voit tout autre consommateur de cet exporteur.

Gestion de la fraîcheur. Même règle 3 × scrape_interval que Scaphandre, avec la jauge Prometheus perf_sentinel_kepler_last_scrape_age_seconds pour la détection de scraper bloqué. La jauge est initialisée à l'instant de démarrage du scraper, donc un endpoint Kepler cassé dès le boot fait quand même progresser la métrique : les alertes Grafana sur un scraper qui n'a jamais réussi se déclenchent correctement. Une réinitialisation de compteur (redémarrage de l'exporteur Kepler) produit un delta négatif que la garde rejette (filtre delta > 0.0 && delta.is_finite(), soustraction f64 classique puisque f64 n'a pas de saturating_sub). Le scrape suivant produit le prochain delta significatif.

Mode batch. Même forme que Scaphandre, analyze ne lance jamais le scraper Kepler. Seul watch intègre Kepler.

Limites de précision Redfish BMC

Perf Sentinel embarque une intégration opt-in avec le standard BMC Redfish pour lire la puissance du châssis des serveurs physiques. Quand [green.redfish] est configuré avec un ou plusieurs endpoints de châssis, le daemon watch interroge chaque endpoint et lit la puissance du châssis à l'emplacement que désigne son schema déclaré : PowerConsumedWatts de l'entrée PowerControl du châssis sur /Power, PowerWatts.Reading sur /EnvironmentMetrics, ou Reading sur un Sensor de puissance du châssis. Il distribue ensuite les joules au niveau du châssis sur les services mappés proportionnellement à leurs opérations, et publie les coefficients par service tagués model = "redfish_bmc".

Exigences plateforme.

  • Un serveur physique dont le BMC est joignable par le daemon. Les serveurs sur site et en colocation conviennent, quelle que soit l'architecture du processeur. Les VMs cloud n'exposent pas de BMC et ne peuvent pas utiliser cette intégration. Les serveurs physiques loués varient : certains hébergeurs donnent accès au BMC, souvent pour une durée limitée ou depuis une adresse autorisée, vérifiez donc que le daemon peut l'interroger en continu. Le firmware doit servir l'une des trois ressources prises en charge, voir Quel schema choisir.
  • HTTPS joignable depuis le daemon vers le BMC. La plupart des BMCs présentent un certificat auto-signé par défaut. Un bundle CA propre à chaque endpoint (ca_bundle_path) est réservé à une version ultérieure. Dans cette release, définir ca_bundle_path empêche le scraper de démarrer avec une erreur claire. Un certificat BMC émis par une CA privée est accepté dès que cette CA figure dans le bundle SSL_CERT_FILE, voir Proxy sortant et CA privée. De même pour un certificat auto-signé placé dans ce bundle, à condition que ses noms alternatifs (SAN) couvrent le nom d'hôte ou l'adresse IP de l'endpoint et qu'il ne soit pas un certificat de CA, car rustls-webpki refuse un certificat serveur dont les contraintes de base indiquent CA:TRUE. Sinon, placez ce BMC derrière un reverse proxy qui présente un certificat de confiance, ou utilisez HTTP sur un segment réseau de confiance.
  • Authentification Basic. L'authentification Session-token Redfish (POST /SessionService/Sessions) n'est pas encore prise en charge. Le champ auth_header porte une ligne Basic au format curl.

Ce que Redfish améliore par rapport au proxy. Une lecture mesurée de la puissance de tout le châssis. Contrairement à Scaphandre et Kepler (qui ne voient que CPU + DRAM, via RAPL), la lecture du BMC couvre les ventilateurs, les disques et les cartes réseau. Les schémas Redfish ne disent pas où cette lecture est prise : PowerConsumedWatts et PowerWatts y sont définis comme la puissance consommée par le châssis, sans mention de l'entrée ou de la sortie de l'alimentation. Les pertes de conversion de l'alimentation ne sont incluses que si votre BMC mesure à l'entrée de l'alimentation. Consultez sa documentation, ou comparez la lecture à la puissance d'entrée que le même BMC rapporte pour chaque alimentation.

Ce que Redfish ne fait PAS. Voici les limites de la puissance au niveau du nœud :

  1. Granularité châssis, pas par service ni par finding. Chaque service mappé au même châssis reçoit le même coefficient (chassis_joules / sum_of_ops_deltas) pour une fenêtre de scrape donnée. Deux services sur le même nœud n'auront jamais de coefficients mesurés distincts via Redfish. Chaque châssis est déclaré une fois : deux identifiants d'endpoint qui désignent un même châssis (même schéma d'URL, hôte, port et chemin /redfish/v1/Chassis/<id>) sont rejetés au chargement. Un châssis qui liste une partie seulement des services d'une requête CPU cloud, ou un domaine sous [green.alumet] label_key = "domain" qui liste une partie seulement des services d'un châssis ou d'une requête CPU, produit un avertissement au chargement : les services laissés de côté gardent une part d'un nœud que le backend plus fidèle facture à nouveau.
  2. Pas d'attribution au niveau processus. Les processus inactifs consomment toujours une puissance de base qui se retrouve allouée aux services actifs. Considérer le coefficient par service comme une borne supérieure de ce que ces services ont tiré.
  3. Pas d'attribution par finding. Même limite que tous les autres tags mesurés de la chaîne.
  4. Variance entre fournisseurs dans la réponse JSON. Les schémas DMTF autorisent null pour chacune des trois lectures. Perf Sentinel rejette les valeurs null/zéro/négatives/NaN comme invalides et garde le coefficient précédent jusqu'à une lecture valide. Les propriétés OEM des fournisseurs ne sont pas configurables : v0.7.6 a typé le schema en enum et a retiré le pointeur JSON tapé par l'opérateur. Les OEMs qui publient la puissance uniquement à un chemin non standard doivent placer le BMC derrière un reverse proxy qui re-formate la réponse vers un schema standard.

Choix du schema et lissage du capteur. Les trois schemas résolvent vers le même tag redfish_bmc en aval, donc le choix concerne uniquement la forme de la donnée. La DMTF ne fixe ni la fréquence de rafraîchissement de ces lectures ni leur lissage, qui dépendent donc du firmware, et deux ressources d'un même BMC peuvent différer. Une ressource Sensor peut indiquer son SensingInterval. Changer de schema sur un châssis peut donc modifier la variance de la série carbone-par-op redfish_bmc. Choisir le schema que sert votre firmware, voir Quel schema choisir.

Énergie cumulée pas encore lue. EnvironmentMetrics expose aussi EnergykWh.Reading (kWh cumulés), qui permettrait un coefficient calculé par delta-integration façon Kepler, strictement plus précis que instantaneous_watts × scrape_interval pour les longs intervalles ou les charges en pic. Le parser actuel lit seulement la puissance instantanée, quel que soit le schema. Une release ultérieure pourra opter pour la lecture cumulative quand la couverture fournisseur sera assez large pour en faire le chemin par défaut.

Protection contre la limitation de débit. scrape_interval_secs est écrêté à [15, 3600] pour Redfish. Un BMC est un petit contrôleur de gestion que d'autres outils interrogent aussi, et l'interroger plus vite que ses lectures ne se rafraîchissent ajoute de la charge sans apporter d'information. Consultez la documentation de votre BMC pour ses limites de requêtes : une requête limitée (HTTP 429) compte comme un scrape en échec. Valeur par défaut : 60 s.

IPMI hors périmètre. Redfish est le standard moderne. Le chemin IPMI nécessiterait de lier ipmitool ou freeipmi en C, ce qui sort de la règle "pas de dépendances lourdes". Documenter toute flotte uniquement IPMI comme une lacune connue.

Gestion de la fraîcheur. Même règle 3 × scrape_interval que les autres sources mesurées, avec perf_sentinel_redfish_last_scrape_age_seconds initialisée à l'instant de démarrage du scraper, donc la jauge progresse depuis le boot même si aucun châssis n'a encore réussi. La jauge est agrégée : elle retombe à zéro dès qu'un châssis réussit son scrape dans un tick. Une flotte multi-châssis avec un BMC sain et plusieurs en échec affiche donc age = 0. Le signal d'échec par châssis vit dans perf_sentinel_redfish_scrape_failed_total{reason=...}. Coupler les deux métriques dans les alertes Grafana qui ont besoin de granularité par châssis.

Mode batch. Même forme, analyze ne lance jamais le scraper Redfish.

Limites de précision du cloud SPECpower

Prérequis de plateforme.

  • Un endpoint compatible Prometheus (Prometheus, VictoriaMetrics, Thanos) qui dispose déjà de métriques d'utilisation CPU issues des exporteurs des fournisseurs cloud (cloudwatch_exporter, stackdriver-exporter, azure-metrics-exporter) ou de node_exporter.
  • Perf Sentinel n'interroge PAS directement les API des fournisseurs cloud. Il lit depuis Prometheus.

Ce que le cloud SPECpower améliore.

Le modèle proxy utilise une constante d'énergie fixe pour toutes les opérations I/O. Le cloud SPECpower la remplace, sur le même périmètre, par une estimation du nœud qui exécute chaque service, qui tient compte de l'utilisation CPU :

watts = idle_watts + (max_watts - idle_watts) * (cpu_percent / 100)
energy_per_op_kwh = (watts / 1000) * (elapsed_secs / 3600) / ops_in_window

elapsed_secs est la durée écoulée depuis le tick de scrape précédent : l'intervalle de scrape, ou plus quand un tick lent retarde le suivant.

Cela capture la variation de puissance proportionnelle à la charge, ce que la constante proxy fixe ne peut pas faire.

Ce que le cloud SPECpower ne fait PAS.

  1. Attribution par finding : comme Scaphandre, c'est un coefficient par service. Il couvre le nœud du service émetteur, jamais la base de données ou le broker qu'il appelle.
  2. Puissance mémoire ou I/O : les données SPECpower couvrent le CPU et la carte mère, pas le stockage ni le réseau.
  3. Un nœud par requête CPU : les services qui lisent une même requête CPU se partagent les watts de son nœud selon leur nombre d'opérations quand la réponse contient une seule série et que les services se résolvent sur une même enveloppe idle et max. Des services aux enveloppes différentes gardent un chiffre par service, avec un avertissement au chargement. Une requête qui renvoie plusieurs séries garde un chiffre par service, chacun lisant la première série, avec un avertissement. Des services d'un même nœud lus par des requêtes différentes ne sont pas reconnus comme voisins, donc chacun est chiffré comme s'il possédait le nœud. Les locataires non tracés du nœud ne sont pas vus non plus : toute la puissance du nœud est attribuée aux services tracés. Une base ou un broker déclaré placement = "shared" sur le nœud fait exception : sa part dynamique est facturée à l'hôte et retirée des watts du nœud avant le partage, le nœud est donc compté une fois. Quatre limites s'appliquent à cette part :
    • Décalage d'échantillonnage. La requête de l'hôte et celle du nœud sont deux séries que Prometheus n'échantillonne pas au même instant, souvent sur des fenêtres de débit différentes, un pic peut donc tomber dans l'une avant l'autre et la part se décale sur ce tick. Les totaux sur une période sont proches, un tick isolé non.
    • Un hôte partagé périmé ne facture rien. Un hôte partagé n'a pas de plafond de repli : un tick sans lecture de l'hôte, ou sans lecture à une seule série de la requête du nœud, ne facture rien à l'hôte, et les services du nœud gardent ses watts sur ce tick.
    • Part dynamique seulement. L'hôte facture (max - idle) × cpu / 100, jamais une part de l'idle du nœud, qui reste aux services. Un hôte qui occupe l'essentiel d'un nœud calme est sous-compté de sa part d'idle.
    • Ordre du budget commun. Les parts des hôtes sont plafonnées aux watts dynamiques du nœud, la base facturée en premier et le broker au plus ce qui reste. Quand les deux sont chargés sur un nœud saturé, le broker absorbe le manque.

Méthodologie unique après la mise à jour du 2026-04-24.

La table embarquée suit une méthodologie unique et homogène : idle_watts = vCPU * idle_per_vCPU_coefficient et max_watts = vCPU * max_per_vCPU_coefficient, avec les coefficients tirés par fournisseur du snapshot Cloud Carbon Footprint ccf-coefficients 2026-04-24. AWS, GCP et Azure partagent uniformément cette approche. La colonne d'overhead baseboard AWS du snapshot 2023-05-01 n'est plus publiée par CCF. Elle a donc été retirée partout. Quand le précédent calcul direct SPECpower_ssj 2008 (2024 Q1 - 2026 Q2) divergeait de CCF de plus de 5 pour cent sur les watts idle ou max, la valeur a été alignée sur CCF par cohérence de source (Sapphire Rapids, EPYC Genoa, Graviton 3/4). Les entrées modernes dont le calcul direct reste dans les 5 pour cent de CCF, ou dont l'architecture est absente du CSV du fournisseur (Emerald Rapids Azure, Genoa Azure, Turin GCP, Ampere Altra GCP, Cobalt 100 Azure), conservent leur valeur SPECpower directe et sont étiquetées explicitement dans table.rs. Conséquence : les instances AWS legacy (m5, c5, r5, m6i) affichent des valeurs plus basses qu'avant parce que l'overhead baseboard n'est plus ajouté. Les instances Sapphire Rapids (m7i, c7i, r7i, GCP c3) affichent des valeurs plus hautes parce que l'agrégat SPECpower CCF est plus récent que notre échantillon direct 2024 Q1.

Graviton 2/3/4 et Cobalt 100 sont estimés, pas mesurés.

AWS ne soumet pas Graviton à SPECpower, Microsoft ne soumet pas Cobalt 100. La table porte des lignes Graviton 2 (m6g, c6g, r6g, t4g), Graviton 3 (m7g, c7g, r7g) et Graviton 4 (m8g, c8g, r8g), et la mise à jour CCF 2026-04-24 leur donne à toutes trois son coefficient EPYC 2nd Gen (0.474 idle / 1.693 max W/vCPU) comme proxy conservateur en l'absence de données mesurées, donc elles partagent le même coefficient CPU par vCPU, et les lignes à mémoire optimisée r6g, r7g et r8g y ajoutent la prime mémoire décrite plus bas. Un .medium Graviton compte 1 vCPU, la moitié d'un .large. AWS revendique publiquement que chaque génération Graviton est plus efficace que la précédente, mais aucune soumission SPECpower n'existe encore pour les différencier. Cobalt 100 (Neoverse N2) est absent du CSV CCF Azure et conserve un mélange au point médian de 0.60/2.20 W/vCPU entre Ampere Altra Q80-30 (Neoverse N1, SPECpower 2024 Q1, 0.67/1.75 W/vCPU comme plancher) et la référence Graviton 3 V1, en attendant des données SPECpower Cobalt directes. Ces valeurs ARM portent une couche d'incertitude supplémentaire : prévoir +/-40% plutôt que +/-30% pour les entrées Graviton, Cobalt 100 et dérivées d'Ampere Altra.

Les noms à modificateur et les noms managés se résolvent sur leur famille de base.

Une famille AWS portant les lettres d (stockage d'instance), n (réseau) ou b (stockage bloc), une taille Azure portant l'option d de disque temporaire, et un nom de classe managée de base de données, de cache, de broker ou de recherche sont valorisés à la ligne de la famille de VM de base, voir Classes de services managés. Le retrait est approximatif quand un modificateur change la génération de CPU : c5n est une puce Skylake alors que les lignes c5 sont Cascade Lake, un écart qui reste dans la fourchette 2x. Le stockage local et les cartes réseau consomment une puissance que le coefficient CPU ne voit pas, comme pour toute autre ligne.

Les séries à plateformes mêlées prennent leur plateforme publiée la plus gourmande.

GCP n1 tourne sur des hôtes Skylake, Broadwell, Haswell, Ivy Bridge ou Sandy Bridge, et Azure Standard_B* (Bv1) sur des hôtes Ice Lake, Cascade Lake, Skylake, Broadwell ou Haswell, sans que la VM choisisse. Leurs lignes prennent l'architecture au max W/vCPU le plus élevé parmi celles que le fournisseur liste et que le CSV publie : Skylake pour n1, puisque le CSV GCP ne publie que Skylake et Broadwell parmi les cinq, et Haswell pour Standard_B*. Standard_E*as_v5 (hôtes Milan ou Genoa) prend EPYC 3rd Gen, la seule des deux présente dans le CSV Azure. La valeur est un majorant pour les hôtes récents d'une série : sur un hôte Cascade Lake, une ligne Standard_B* affiche environ 1,5 fois le max Cascade Lake. Les familles ajoutées avant cette règle gardent leur correspondance unique, par exemple Standard_D*s_v5 sur Cascade Lake. Les objectifs Azure SQL de série standard (Gen5) suivent la même règle : des sept processeurs que Microsoft liste, le CSV Azure publie Broadwell, Skylake, Cascade Lake et EPYC 3rd Gen, et ils prennent Skylake via Standard_D64s_v3, mis à l'échelle par vCore.

Les objectifs Azure SQL serverless sont valorisés à leur maximum de vCores.

Un objectif serverless GP_S_Gen5_N ou HS_S_Gen5_N est valorisé comme N vCores provisionnés, un plafond. La mise en pause automatique, le minimum de vCores et l'autoscaling entre les deux ne sont pas modélisés, pas plus que la mémoire qu'une base serverless peut atteindre au-delà des 5,1 Go par vCore de Gen5 provisionné, donc aucune prime DRAM ne s'applique.

Les familles burstable comptent tous leurs vCPU.

AWS t3, t3a, t4g et Azure Standard_B* sont valorisées comme si chaque vCPU pouvait tourner à 100 pour cent, puisque CCF ne modélise aucun crédit de rafale. Une VM burstable tenue sous sa ligne de base n'atteint jamais le max de la ligne, et son idle est celui de vCPU complets, donc elle est surestimée aux deux bouts.

EPYC 5th Gen Turin est proxié sur Genoa en attendant une correction amont de CCF.

L'entrée CCF 2026-04-24 pour EPYC 5th Gen Turin est 3.682 idle / 8.961 max W/vCPU, soit environ cinq fois plus haut que le coefficient voisin EPYC 4th Gen Genoa (0.739 / 2.282) sur la même structure de table. La soumission SPECpower amont qui alimente cette ligne a probablement été mesurée au niveau chip plutôt que thread, ou reflète un échantillon trop petit pour généraliser. Nous remplaçons la valeur de Turin (AWS m8a / c8a) par le coefficient Genoa plutôt que d'importer la ligne CCF telle quelle. Une inflation silencieuse 4x sur les clients m8a dégraderait la crédibilité du signal directionnel de gaspillage de l'outil, tandis qu'un proxy Genoa est au pire conservateur et au mieux correct, puisque Zen 5 est censé être au moins aussi efficient que Zen 4 par thread. Ce remplacement est suivi ici pour ré-évaluation quand CCF publiera une ligne EPYC 5th Gen révisée ou quand des soumissions SPECpower indépendantes pour EPYC 9755 / 9655 sortiront. Prévoir +/-40% d'incertitude sur Turin en attendant.

Les SKUs memory-optimized portent un premium DRAM additif en plus du coefficient CPU.

CCF 2026-04-24 ne publie pas de premium memory-class, nous en ajoutons donc un sur le coefficient CPU par vCPU pour les familles memory-optimized : r5, r5a, r6i, r7i, r7a, r6g, r7g, r8g sur AWS, n2-highmem-* sur GCP, et Standard_E* v3 à v6 sur Azure, Standard_E*as_v5 compris. GCP n1-highmem-* porte 6,5 Gio par vCPU, sous le ratio de 8 Gio, et ne reçoit pas de premium. Le premium est 0.02 W/GB idle et 0.05 W/GB max (datasheets Crucial DDR4 RDIMM, modèle Boavizta DIMM), et le ratio mémoire 8 GB/vCPU de ces familles donne une majoration par vCPU de +0.16 idle / +0.40 max. C'est l'une des deux déviations méthodologiques par rapport au CSV dans la mise à jour 2026-04-24 (le remplacement de Turin étant l'autre), documentée inline dans table.rs. Les entrées memory-optimized r-series sur silicium AMD (r5a sur EPYC 1st Gen, etc.) reçoivent la même majoration que les r-series Intel puisque la DRAM est indépendante de l'architecture CPU. Les familles general-purpose (m5, m6i, etc.) portent environ 4 GB/vCPU de DRAM, les familles compute-optimized (c5, c6i, etc.) environ 2 GB/vCPU. Ni les unes ni les autres ne reçoivent le premium sous la règle actuelle, ce qui sous-estime leur idle d'environ 6 à 8 pour cent (m-series) et 3 à 4 pour cent (c-series). Les deux restent dans la fourchette d'incertitude 2x, et nous n'appliquons pas de demi-premium pour ne pas composer la divergence méthodologique avec CCF.

Modèle mental correct.

Le cloud SPECpower est un modèle d'interpolation d'une précision d'environ +/-30%. C'est un cran au-dessus du proxy I/O (estimation d'ordre de grandeur), mais moins précis que Scaphandre RAPL (mesure matérielle directe).

Mode batch.

Le cloud SPECpower est une fonctionnalité réservée au daemon (mode watch). La commande batch analyze utilise toujours le modèle proxy.

Coefficients énergétiques par opération

Les multiplicateurs d'énergie par opération (pondération par verbe SQL, paliers de taille de payload HTTP) sont des estimations heuristiques dérivées de benchmarks académiques d'énergie SGBD (Xu et al. ICDE 2010, Tsirogiannis et al. SIGMOD 2010) et de la méthodologie Cloud Carbon Footprint. Ce sont des poids relatifs qui classent les familles d'opérations du service émetteur : les benchmarks ont mesuré des serveurs de base de données, mais les poids ne facturent aucun hôte de base de données. Les ratios relatifs entre opérations (SELECT < DELETE < INSERT/UPDATE) sont plus fiables que les valeurs absolues, qui varient selon les générations de matériel et les moteurs de bases de données.

Limitations :

  • Pas d'analyse de complexité de requête. Un SELECT avec full table scan coûte plus d'énergie qu'un point lookup indexé, mais les deux reçoivent le même coefficient 0.5x. Les coefficients capturent la classe d'opération moyenne, pas le plan d'exécution propre à la requête. Une requête longue pèse aussi une opération : duration_us n'est jamais lu, donc son coût d'hôte n'est pas modélisé.
  • La taille du payload HTTP nécessite des attributs OTel. L'attribut http.response.body.size (ou l'ancien http.response_content_length) doit être présent sur les spans HTTP. Quand il est absent, le coefficient retombe à 1.0x (la constante de base). La plupart des bibliothèques d'instrumentation HTTP n'émettent pas cet attribut par défaut.
  • Non utilisé avec l'énergie mesurée. Quand un backend mesuré fournit de l'énergie par service, les coefficients par opération sont ignorés : la lecture mesurée remplace le proxy sur le même périmètre, l'hôte du service émetteur.

Mettre per_operation_coefficients = false pour désactiver cette fonctionnalité et utiliser la constante d'énergie fixe pour toutes les opérations.

Énergie de transport réseau

Le terme optionnel d'énergie de transport réseau estime le coût énergétique du transfert d'octets entre régions. Le coefficient (0.04 kWh/Go, fixe depuis la 0.9.25 pour que chaque divulgation mette le transport à la même échelle) est une valeur prudente sous les moyennes réseau récentes (Sustainable Web Design Model v4, 2024 : 0.059 kWh/Go opérationnel pour les réseaux) et une borne haute pour le trafic serveur inter-régions, où les coefficients inter-datacenters descendent à 0.001 kWh/Go (Cloud Carbon Footprint). La divulgation publie le terme sous cette fourchette sourcée 0.001-0.059 à côté de la valeur médiane.

Limitations :

  • Large plage d'estimation. Les sources citées plus haut vont de 0.001 à 0.059 kWh/Go selon l'étude, l'année et le périmètre (backbone seul ou chemin complet). Le coût réel dépend du nombre de sauts, de la distance et de l'infrastructure.
  • Pas d'effets CDN ou compression. Les réseaux de distribution de contenu, la compression HTTP et la réutilisation de connexions réduisent tous l'énergie de transport effective, mais ne sont pas modélisés.
  • Détection inter-région basée sur la config. La région de l'appelé est déterminée en cherchant le hostname cible dans [green.service_regions]. Si le hostname n'est pas mappé, Perf Sentinel suppose par prudence la même région (pas de terme de transport). L'énergie de transport n'est donc calculée que lorsque l'utilisateur configure explicitement des mappings de services inter-régions.
  • Pas de modélisation du dernier kilomètre. L'estimation couvre le transport backbone. Le coût énergétique du dernier kilomètre (réseau de bordure, terminal client) est exclu.
  • Hypothèse de proportionnalité linéaire. Le modèle kWh/Go suppose que l'énergie augmente linéairement avec le volume de données. Mytton et al. (2024) montrent que c'est une simplification : les équipements réseau ont une puissance de base fixe significative indépendante du trafic. L'estimation est directionnelle, pas précise.
  • Corps de réponse uniquement. Seule la taille du corps de réponse (http.response.body.size) est comptée. Le corps de requête (ex. payloads POST volumineux) n'est pas disponible dans les conventions sémantiques OTel HTTP standard et est exclu. Pour les APIs à écriture intensive, cela sous-estime l'énergie de transport.
  • Intensité carbone de l'appelant appliquée au réseau. L'infrastructure réseau est distribuée sur plusieurs réseaux électriques, mais Perf Sentinel utilise l'intensité carbone de la région de l'appelant comme proxy. C'est une simplification connue, cohérente avec l'approche d'estimation directionnelle.

Le terme est calculé, affiché et publié à chaque run. include_network_transport est dépréciée et ignorée depuis la 0.9.25 : deux périodes en désaccord sur ce réglage n'étaient pas comparables, le réglage ne laissait aucune trace dans les chiffres publiés, et un interrupteur d'affichage sur un chiffre publié n'avait plus non plus de justification.

Détection des services bavards (chatty service)

Le détecteur de services bavards ne compte que les spans HTTP sortants (type: http_out). Une trace avec 15 requêtes SQL vers la même base de données n'est pas "bavarde" au sens inter-services. Le seuil est par trace, pas par endpoint : une trace répartie sur 3 endpoints faisant chacun 6 appels (18 au total) déclenchera au niveau de la trace même si aucun endpoint individuel n'est particulièrement bavard.

Les findings de type chatty service ne sont PAS comptées comme I/O évitables dans le ratio de gaspillage. Elles représentent un problème architectural (granularité de décomposition des services), pas une opportunité de regroupement.

Détection de saturation du pool de connexions

Le détecteur de saturation du pool utilise une heuristique basée sur le chevauchement temporel des spans SQL, pas les métriques réelles du pool de connexions. Il calcule la concurrence maximale en traitant chaque span SQL comme un intervalle [start, start + duration] et en exécutant un algorithme de balayage (sweep line).

Limitations :

  • Les horodatages du tracing distribué peuvent présenter un décalage d'horloge, entraînant une détection imprécise du chevauchement.
  • Le détecteur ne peut pas distinguer entre une contention réelle du pool et des requêtes parallèles intentionnelles (par exemple, des patterns scatter-gather).
  • Le pic se mesure au sein d'une seule trace. Plusieurs requêtes entrantes concurrentes qui tiennent chacune une connexion passent donc inaperçues, car les spans ne portent aucune identité de processus et une somme sur plusieurs traces additionnerait les pools de toutes les instances du service. Pour cette contention comme pour une surveillance précise, instrumentez votre application avec les métriques OTel du pool de connexions (db.client.connection.pool.usage, db.client.connection.pool.wait_time).

Les findings de saturation du pool ne sont PAS comptées comme I/O évitables.

Détection des appels sérialisés

Le détecteur d'appels sérialisés signale les spans frères séquentiels (même parent_span_id) qui appellent des services ou endpoints différents et pourraient potentiellement être exécutés en parallèle. La sévérité est info pour refléter l'incertitude inhérente.

Considérations sur les faux positifs :

  • Des appels séquentiels au même service PEUVENT avoir des dépendances de données légitimes que l'outil ne peut pas observer (par exemple, "créer un utilisateur" puis "envoyer un email de bienvenue" où l'email a besoin de l'ID utilisateur).
  • Le détecteur ignore les séquences où tous les appels partagent le même template normalisé (ce pattern est du N+1, pas de la sérialisation).
  • Le champ parent_span_id doit être présent sur les spans pour que ce détecteur fonctionne. Les traces sans relations parent-enfant (par exemple une ingestion JSON à plat sans IDs de span) ne déclencheront pas de findings de sérialisation.

Le détecteur remonte au maximum un finding par span parent : la plus longue sous-séquence non chevauchante (trouvée par programmation dynamique). Si un parent contient deux groupes distincts d'appels sérialisables séparés par des spans chevauchants, seul le groupe le plus long est rapporté.

Les findings d'appels sérialisés ne sont PAS comptées comme I/O évitables. Elles représentent une opportunité d'optimisation de latence, pas une réduction d'I/O.

Corrélation cross-trace

La corrélation temporelle cross-trace ([daemon.correlation]) nécessite le mode daemon (perf-sentinel watch) avec un trafic soutenu et représentatif. Les corrélations sont statistiques : elles détectent des co-occurrences temporelles, pas des relations causales. Une corrélation élevée entre un N+1 dans le service A et une saturation du pool dans le service B signifie qu'ils co-surviennent fréquemment dans le délai configuré, pas que l'un cause l'autre.

Limitations :

  • Démarrage à froid. Le corrélateur a besoin de temps pour accumuler suffisamment d'observations. Avec min_co_occurrences = 3 et une fenêtre de 10 minutes, il faut au moins 3 co-occurrences en 10 minutes avant qu'une corrélation remonte. Les environnements à faible trafic peuvent ne jamais atteindre ce seuil.
  • Mode batch non pris en charge. La commande analyze ne lance pas le corrélateur. La corrélation cross-trace est intrinsèquement une préoccupation du streaming.
  • Cardinalité. Le plafond max_tracked_pairs (défaut 10000) empêche la croissance mémoire non bornée. Si vous avez de nombreux types de findings distincts sur de nombreux services, certaines paires peuvent être évincées avant d'atteindre le seuil de co-occurrences. L'éviction retire d'abord les paires au compteur le plus bas, puis les plus anciennes.
  • Endpoints distincts. Chaque endpoint distinct (type de finding, service, regroupement, template) vu dans la fenêtre est conservé jusqu'à ce que la fenêtre l'oublie, sans plafond. Un service dont les templates se normalisent mal (littéraux restés dans le SQL ou les URL) garde chaque variante pendant tout window_minutes.
  • Portée d'appariement. Le deque d'appariement couvre les findings analysés dans les derniers lag_threshold_ms + 2 x trace_ttl_ms, environ une minute avec le TTL par défaut de 30 s, et chaque finding entrant le parcourt. Sa taille et ce parcours croissent avec trace_ttl_ms, pas avec window_minutes.
  • Traces longues. Les findings s'apparient sur leurs propres horodatages de premier span, mais un finding ne peut rencontrer que ceux analysés à environ lag_threshold_ms + 2 x trace_ttl_ms de lui. Une trace qui reste ouverte plus d'environ un trace_ttl_ms arrive tard à l'analyse et peut manquer des paires avec les findings de traces plus courtes.
  • Horodatages. Un finding dont le first_timestamp est absent ou non UTC est placé à l'instant où le daemon l'a analysé. Un décalage d'horloge entre hôtes fausse le délai mesuré et peut inverser source et cible.

Pour consommer les corrélations :

  • Lancer un daemon : perf-sentinel watch --otlp-grpc 0.0.0.0:4317.
  • Interroger : perf-sentinel query correlations.
  • Ou ouvrir le dashboard généré par perf-sentinel report à partir d'un payload qui contient des corrélations (seuls les rapports produits par le daemon en contiennent).

Le mode batch analyze renvoie toujours un tableau de corrélations vide. C'est attendu, pas un bug.

Attributs de code source OTel

Les findings incluent un champ code_location (avec function, filepath, lineno, namespace) quand les spans OTel portent les attributs code.* correspondants : les noms stables code.function.name, code.file.path et code.line.number, ou les noms legacy code.namespace, code.function, code.filepath et code.lineno. En OTLP, un span d'I/O qui n'en porte aucun prend la localisation de code de son plus proche ancêtre qui en a une, dans le même service et jusqu'à huit niveaux au-dessus. Cela permet des annotations au niveau source dans les rapports SARIF (annotations inline GitHub/GitLab).

Limitations :

  • La plupart des agents d'auto-instrumentation OTel n'émettent ni numéro de ligne ni chemin de fichier. Une instrumentation manuelle ou une configuration spécifique de l'agent est nécessaire, et le starter Spring Boot n'émet par défaut aucun attribut code.* (voir Instrumentation). En OTLP, code.line.number et code.lineno ne sont lus que comme entiers. Sans ces attributs, les findings apparaissent sans localisation source (pas de bruit, dégradation gracieuse).
  • Le nom de fonction est l'attribut le plus souvent disponible. Si seul code.function.name ou code.function est présent, la CLI l'affiche mais SARIF ne peut pas produire de physicalLocation (qui nécessite au minimum un chemin de fichier).
  • Les numéros de ligne peuvent être approximatifs. Certains agents rapportent le point d'entrée de la méthode, pas la ligne exacte de l'appel I/O.
  • Les findings structurels pointent sur un appel représentatif. serialized_calls, excessive_fanout, chatty_service et pool_saturation couvrent plusieurs appels, leur code_location est donc celui d'un seul appel (le premier de la séquence, le premier enfant, le premier appel HTTP, le premier span SQL), pas celui du pattern entier.
  • Les valeurs code.filepath hostiles sont supprimées du SARIF. L'attribut OTel code.filepath peut être contrôlé par un attaquant. Avant émission comme artifactLocation.uri SARIF, Perf Sentinel rejette les chaînes de type URI, les chemins absolus, le path traversal (littéral et percent-encodé), les séquences double-encodées, les préfixes UTF-8 overlong, les caractères de contrôle et les caractères Unicode BiDi/invisibles (classe Trojan Source). Les findings au filepath rejeté apparaissent toujours dans le rapport, sans location.

API de requêtage du daemon

La sous-commande perf-sentinel query et les endpoints HTTP /api/* exposent l'état interne du daemon. L'API de requêtage n'a pas d'authentification ni d'autorisation par identité : des clés partagées protègent les écritures, la liste des acks et les incidents, et chaque lecture dès que [daemon] require_read_key = true (voir API de query). Tout autre contrôle d'accès doit être géré extérieurement via des politiques réseau ou un reverse proxy, comme pour les endpoints d'ingestion OTLP. Voir "Pas d'authentification" ci-dessus.

  • Kill-switch. Mettre [daemon] api_enabled = false désactive toutes les routes /api/* tout en conservant l'ingestion OTLP et /metrics. Utilisez cette option quand le daemon tourne dans un environnement où même l'exposition en loopback des findings est inacceptable. /metrics expose toujours les compteurs de findings via perf_sentinel_findings_total et métriques associées : le flag de l'API ne supprime donc pas toute sortie observable.
  • La mémoire n'est pas libérée par api_enabled = false seul. Le buffer circulaire FindingsStore est toujours peuplé à chaque tick même quand l'API est désactivée, car la détection tourne avant la vérification de l'API. Pour libérer cette mémoire, mettez [daemon] max_retained_findings = 0. Cela court-circuite le push_batch du store et garde le RSS du daemon minimal quand l'API de requêtage est désactivée.
  • Taille de réponse plafonnée. /api/findings plafonne à 1000 entrées par requête (le paramètre ?limit= est plafonné). /api/correlations tronque au top 1000 par confiance. Ces plafonds protègent contre les requêtes coûteuses quand le daemon a accumulé une grosse empreinte mémoire.
  • Les findings retenus sont bornés. Le buffer circulaire FindingsStore (défaut 10 000 findings) évince les entrées les plus anciennes quand il est plein. Pour les daemons à fort trafic, augmentez max_retained_findings ou acceptez que les findings plus anciens ne seront pas interrogeables.
  • Pas de persistance. Le daemon stocke les findings en mémoire uniquement. Un redémarrage efface tous les findings retenus et l'état de corrélation. Pour examiner des traces plus anciennes que la fenêtre live de 30 secondes (incidents de production regardés après coup), voir Runbook.
  • La livraison Hub est bornée, fusionnée et non durable. Quand [daemon.hub_export] est activé, une signature répétée remplace sa valeur en attente. Après acquittement du Hub, elle n'est pas renvoyée pendant une heure sauf aggravation de sévérité. max_pending borne à la fois les signatures en attente et récemment acquittées. Les évictions par capacité sont visibles via perf_sentinel_hub_export_dropped_total. Un redémarrage perd ce que le Hub n'a pas encore acquitté et oublie les acquittements récents, donc une signature active peut être renvoyée sans danger. Le polling horaire du Hub reste un filet de récupération, mais /api/findings renvoie au maximum 1000 entrées : en production soutenue, utilisez l'export push plutôt que le polling seul.
  • L'historique des findings dans Grafana va aussi loin que le Hub, à la granularité du jour. La rangée History (Hub) du dashboard findings lit PerfSentinelHub (0.3.0 et suivants), pas le daemon : sans Hub le dashboard ne peut pas remonter dans le temps, quoi que dise le sélecteur. Le Hub enregistre les jours où il a observé un finding, à son horloge en UTC, à partir du jour de sa mise à niveau, les garde pendant Hub:Retention (180 jours par défaut), et n'enregistre aucun compte d'occurrences, qui restent dans perf_sentinel_findings_total côté Prometheus. Les colonnes d'acquittement de la rangée demandent des daemons à partir de 0.24.0, dont le Hub reflète le GET /api/acks?include_toml=true.

Ingestion automatisée pg_stat depuis Prometheus

Le flag --prometheus de pg-stat scrape les métriques exposées par postgres_exporter. Cela nécessite :

  • Une instance postgres_exporter en cours d'exécution configurée pour collecter les métriques pg_stat_statements.
  • L'endpoint Prometheus doit être joignable depuis la machine exécutant Perf Sentinel.
  • Seules les métriques disponibles dans l'exporteur Prometheus sont utilisées. Certains champs présents dans la vue pg_stat_statements brute (ex. blk_read_time, blk_write_time) peuvent ne pas être exposés par toutes les versions de l'exporteur.

Le mode --input par fichier existant est inchangé et reste l'approche recommandée pour les pipelines CI.

pg-stat et mysql-stat acceptent tous deux --metric, --query-label et --calls-metric pour nommer la série de temps, le label qui porte le texte de la requête et la série du compteur d'appels, car un exporteur qui exécute une requête écrite à la main ou une recording rule nomme les siens. mysql-stat ajoute --rows-sent-metric et --rows-examined-metric pour les deux autres séries de compteurs qu'il joint, plus --schema-label, puisque le schéma fait partie de l'identité sur laquelle ses requêtes agrègent. Une valeur vide sur n'importe quel flag de compteur saute la requête correspondante. Les deux prennent aussi --unit : seconds|milliseconds pour pg-stat, seconds|milliseconds|picoseconds pour mysql-stat, puisque Performance Schema compte SUM_TIMER_WAIT en picosecondes et qu'une recording rule transmet en général cette colonne telle quelle. Sans correspondance de label, le classement retombe sur l'identifiant opaque (queryid, digest) plutôt que de fondre toutes les lignes en une seule. Les deux collectes agrègent une requête sur les bases et les utilisateurs que l'exporteur lui attache, donc une requête occupe une ligne de classement. Côté MySQL, le schéma fait partie de cette identité et reste visible. Côté PostgreSQL, datname et user sont fondus puisque le rapport ne les porte pas. instance et job restent dans l'identité, si bien qu'un Prometheus qui scrape plusieurs serveurs de base de données classe la copie de chaque serveur sur ses propres chiffres, plutôt que de les sommer en une ligne qui ne nomme aucun serveur.

Les deux prennent aussi --selector, des matchers de labels sur lesquels toutes les requêtes filtrent ('namespace="shop",db_role="primary"'), pour un exporteur qui sert plusieurs tenants ou un primaire avec ses réplicas : sans filtre, leurs lignes sont classées ensemble. Deux limites restent. Les compteurs sont cumulés depuis le dernier pg_stat_statements_reset() (ou le redémarrage du serveur côté MySQL), donc le classement couvre toute cette durée et non la fenêtre que regarde une analyse. Et la navigation depuis un finding compare le SQL normalisé au texte de requête que porte le label, donc une requête d'exporteur qui tronque ce texte laisse les longues requêtes sans correspondance.

Ingestion automatisée mysql-stat depuis Prometheus

Le flag --prometheus de mysql-stat scrape le collecteur perf_schema.eventsstatements de mysqld_exporter, qui est désactivé par défaut : l'exporteur a besoin de --collect.perf_schema.eventsstatements.

Cette série porte le temps d'exécution cumulé. Le collecteur publie COUNT_STAR, SUM_ROWS_SENT et SUM_ROWS_EXAMINED comme des séries à part, récupérées par une requête chacune et jointes sur l'identité du digest, ce qui alimente les quatre classements :

  • Une série de compteur que l'exporteur ne publie pas laisse sa colonne à 0 plutôt qu'un chiffre inventé, et journalise un WARN qui la nomme. Sans le compteur d'appels en particulier, mean_exec_time répète total_exec_time et deux classements perdent leur signal.
  • L'identité est digest plus le label de schéma. Une recording rule qui renomme schema fondrait deux schémas en une ligne avec leurs temps sommés, d'où --schema-label pour le nommer.

Un export fichier de performance_schema (--input) n'exige aucun collecteur activé sur l'exporteur et porte AVG_TIMER_WAIT mesuré plutôt que dérivé. Préférez-le dès que l'export est disponible. Le chemin Prometheus vise les parcs où seul l'exporteur est joignable.

Secrets et credentials

Perf Sentinel ne stocke jamais de secrets dans sa sortie de config. Pour les scrapers qui ont besoin de credentials, le pattern "variable d'environnement préférée" s'applique partout :

  • Clé API Electricity Maps : variable PERF_SENTINEL_EMAPS_TOKEN. Un [green.electricity_maps] api_key dans le fichier de config fonctionne mais émet un avertissement au chargement, car les fichiers de config versionnés sont une source fréquente de fuites de credentials.
  • Connection string PostgreSQL pour pg-stat --connection-string : variable PERF_SENTINEL_PG_CONNECTION. Passer une connection string avec mot de passe en clair sur la CLI fonctionne aussi mais émet un avertissement (recommandé : .pgpass en production).
  • URLs d'endpoints scraper (Scaphandre, cloud energy, Electricity Maps, pg-stat Prometheus) : les credentials dans l'URL (http://user:pass@host) sont rejetés au chargement de la config. Utilisez le mécanisme d'authentification natif du scraper.
  • Fichier de clé TLS : les permissions de [daemon] tls_key_path sont vérifiées au démarrage, et une clé lisible par le groupe ou les autres émet un avertissement.

Le daemon n'écrit jamais de secrets sur stdout/stderr : tous les chemins d'erreur des scrapers utilisent redact_endpoint pour retirer les userinfo de toute URL avant de journaliser.

Quand le daemon tourne avec api_enabled = true, l'API de requêtage expose les findings (pas les secrets) sans authentification par identité, et sans aucune clé sauf si [daemon] require_read_key = true. Restreignez l'accès loopback via des politiques réseau ou un reverse proxy ou mettez api_enabled = false pour désactiver toute la surface API.

API Electricity Maps

  • Clé API requise. L'intégration Electricity Maps nécessite une clé API. L'offre gratuite est réservée aux usages non commerciaux, un déploiement en production demande donc une offre payante. La clé doit être fournie via la variable PERF_SENTINEL_EMAPS_TOKEN plutôt que dans le fichier de config.
  • HTTPS fortement recommandé. Quand l'endpoint configuré est http:// (en clair) et qu'un auth token est défini, Perf Sentinel émet un avertissement au chargement de la config. L'API production d'Electricity Maps est servie uniquement en HTTPS, donc un endpoint http:// est presque toujours une erreur de configuration ou un environnement de test local.
  • Limites de débit. Chaque offre a son propre quota de requêtes. Une zone coûte 8 640 requêtes par mois avec le poll_interval_secs = 300 par défaut et environ 720 à 3600, dimensionnez donc l'intervalle selon l'offre, ou tenez-vous-en aux profils horaires embarqués.
  • Mode daemon uniquement. Le scraper Electricity Maps ne fonctionne qu'en mode perf-sentinel watch. Le mode batch (analyze, tempo, calibrate) utilise les profils embarqués.
  • Repli en cas de données périmées. Si l'API est inaccessible plus longtemps que 3x l'intervalle de sondage, le scraper retombe sur les profils horaires ou annuels embarqués.

Flux des gestionnaires de réseau (RTE éCO2mix, NESO, Fingrid, Energinet, REE e·sios, Open Electricity, MAVIR, em6)

  • Un pays chacun, valeurs nationales ou par zone d'enchères. Chaque région rattachée à un flux reçoit la valeur de son réseau national (ou de DK1 / DK2, ou d'une région australienne), ce qui est faux pour une région hors de ce réseau. NESO couvre la Grande-Bretagne, pas l'Irlande du Nord, et REE e·sios l'Espagne péninsulaire, pas les îles.
  • Pas la même base. RTE, Fingrid 266, REE e·sios, Open Electricity, MAVIR et em6 comptent les émissions directes de la production nationale. NESO, Fingrid 265 et Energinet comptent la consommation, imports compris. Aucun n'est en cycle de vie, tous sont donc plus bas qu'une intensité Electricity Maps lifecycle, et une base production sous-estime un réseau qui importe beaucoup. Comparez les régions entre elles en gardant cela en tête.
  • Des estimations, pas des chiffres consolidés. RTE remplace ensuite ses valeurs temps réel par des valeurs consolidées, Energinet dit que ses valeurs initiales ne seront en général pas corrigées, et l'actual de NESO est une estimation tirée de la production mesurée ; Perf Sentinel ne revient jamais sur une valeur passée. NESO cesse parfois de mettre actual à jour et le recopie sur chaque nouveau créneau ; Perf Sentinel considère comme figée une estimation identique sur 4 créneaux consécutifs alors que la prévision bouge, et utilise alors la prévision la plus récente, ce qui veut dire aussi qu'un réseau réellement stable dont la prévision bouge lit la prévision.
  • Des chiffres calculés ou déduits. Open Electricity ne publie pas d'intensité : Perf Sentinel la calcule à partir des émissions et de la production par technologie, au dernier intervalle que couvre le solaire en toiture, donc une valeur australienne peut avoir une heure et demie. MAVIR n'imprime pas d'unité : les tCO2 par MWh sont déduites de ses séries d'émissions et de production, et le quart d'heure en cours qu'il publie est révisé après sa clôture.
  • Pas de métadonnées d'estimation. Les lignes ne portent pas d'indicateur intensity_estimated, aucune de ces API n'en expose.
  • Conditions d'utilisation. NESO interdit de vendre ou de réhéberger l'accès à son API ; Energinet demande aux éditeurs de ne pas interroger depuis chaque unité d'une application ; Fingrid limite chaque clé à 10 000 appels par jour et un toutes les 2 secondes. Une clé partagée entre de nombreux daemons peut atteindre les limites de Fingrid, et l'offre gratuite d'Open Electricity permet 500 appels par jour. REE e·sios interdit l'usage commercial, la CC BY-NC 4.0 d'Open Electricity l'exclut, et MAVIR comme em6 n'accordent pas de licence ouverte : Configuration cite leurs conditions.
  • Proxys d'entreprise. Certains proxys qui inspectent le TLS bloquent un domaine entier (opendatasoft.com pour RTE a été vu bloqué). Le scraper échoue alors et le scoring retombe sur les profils embarqués, avec un avertissement après 3 échecs consécutifs.
  • Mode daemon uniquement. Le mode batch utilise les profils embarqués.

Ingestion Tempo

  • Format protobuf. La sous-commande perf-sentinel tempo demande les traces en protobuf OTLP depuis l'API HTTP de Tempo. Tempo doit être configuré pour servir des réponses protobuf (le défaut).
  • Plafond de concurrence sur la récupération parallèle. Le parcours recherche puis récupération (--service --lookback) récupère les corps de trace en parallèle via un tokio::task::JoinSet, plafonné par défaut à 16 requêtes en vol par un sémaphore. --fetch-concurrency ou [tempo] fetch_concurrency règle ce plafond de 1 à 64. Délai d'expiration de 30s par récupération (contre 5s pour l'étape de recherche) pour laisser le temps d'assembler le corps d'une trace à fort fanout depuis les ingesters et le stockage long terme. Sur un Tempo sous-dimensionné avec des fenêtres longues (24h par exemple), certaines récupérations peuvent malgré tout expirer. Le remède : baisser ce plafond, ou côté Tempo augmenter les replicas de tempo-query-frontend, ajuster max_search_duration et max_concurrent_queries.
  • Mémoire d'une exécution. Une exécution garde ses traces en mémoire. Mesuré sur le binaire Linux 0.43.1 contre un serveur factice avec des traces synthétiques, 16 lectures à la fois : 137 Mio de pic de mémoire résidente pour 100 traces de 200 spans, 312 Mio pour 500 de 200, 446 Mio pour 100 de 2 000, 1 751 Mio pour 500 de 2 000. --memory-limit-mib plafonne l'espace d'adressage d'un processus sous Linux, donc une exécution qui dépasse le plafond s'arrête d'elle-même, par SIGABRT (code 134 vu d'un shell) et non par l'un des codes de sortie de CI. L'espace d'adressage dépasse la mémoire résidente, gardez donc une marge : sur le binaire Linux arm64 de 0.43.2, 500 traces de 200 spans passent toujours sous 448 Mio et s'arrêtent toujours sous 352 Mio, 100 traces de 2 000 spans passent toujours sous 576 Mio et s'arrêtent toujours sous 512 Mio. La frontière varie d'une exécution à l'autre. Les commandes lancées par le processus héritent du plafond : celle qu'enveloppe capture, à laquelle une JVM survit rarement, ainsi que cosign et gh sous verify-hash.
  • Ctrl-C préserve les résultats partiels. Interrompre une longue récupération parallèle annule toutes les tâches en vol et retourne les traces déjà complétées, et le rapport le dit dans un warning partial_fetch. La CLI renvoie l'erreur dédiée TempoError::Interrupted si zéro trace n'a eu le temps de se compléter avant le signal, pour que les quality gates CI distinguent une interruption par l'opérateur d'un vrai résultat vide (NoTracesFound).
  • API de recherche. Le mode recherche utilise l'endpoint GET /api/search de Tempo, qui peut ne pas être disponible sur tous les déploiements Tempo (la fonctionnalité de recherche doit être activée dans Tempo).

Constante énergétique gCO2eq (section legacy, conservée pour les références croisées)

L'estimation carbone utilise une constante énergétique fixe (0,1 mWh par opération I/O) comme approximation grossière d'ordre de grandeur. Voir Précision des estimations carbone ci-dessus pour la méthodologie complète et l'avertissement.

Ingestion pg_stat_statements

  • Pas de corrélation par trace. Les données pg_stat_statements n'ont pas de trace_id ni de span_id. Elles ne peuvent pas servir à la détection d'anti-patterns par trace (N+1, redondant). Elles fournissent une analyse complémentaire de hotspots et une référence croisée avec les findings basés sur les traces. Comme les digests performance_schema, elles n'alimentent que les classements, jamais l'énergie, le carbone ni le gaspillage : les seuls signaux d'énergie d'hôte de base de données sont un cgroup mesuré ([green.alumet.database]), un cluster déclaré ([green.database_static]) et la requête CPU des hôtes déclarés (sa cpu_query, sur des hôtes à elle ou sur un nœud applicatif).
  • Parsing CSV. Le parseur CSV gère le quoting RFC 4180 (champs entre guillemets doubles, "" échappé), mais suppose une entrée UTF-8. Les fichiers non-UTF-8 échoueront au parsing.
  • Requêtes pré-normalisées. PostgreSQL normalise les requêtes pg_stat_statements au niveau du serveur. Perf Sentinel applique sa propre normalisation par dessus pour la référence croisée, ce qui peut produire des templates légèrement différents.
  • Pas de connexion directe à PostgreSQL. En mode fichier (--input), Perf Sentinel lit des fichiers CSV ou JSON exportés. Le flag --prometheus scrape les métriques postgres_exporter au lieu de se connecter directement à PostgreSQL. Voir "Ingestion automatisée pg_stat depuis Prometheus" ci-dessus pour les limitations spécifiques au mode Prometheus.
  • Nombre d'entrées. Le parseur pré-alloue la mémoire en fonction de la taille de l'entrée, plafonné à 100 000 entrées. Les fichiers dépassant 1 000 000 d'entrées (lignes CSV ou éléments de tableau JSON) sont rejetés avec une erreur pour prévenir l'épuisement mémoire.

Cartes à partager

Une carte à partager prouve qu'une clé a signé ces chiffres et que personne ne les a changés depuis, rien de plus. Voir SHARE-CARD pour le contrat.

  • Pas d'horodatage tiers. Le jour signé est une affirmation du signataire. Une clé retirée se porte encore garante de toute carte datée dans sa fenêtre, donc qui vole une clé retirée peut y dater une carte. Révoquer la clé ferme cette porte, au prix de toutes les cartes qu'elle a signées, honnêtes comprises.
  • Les réseaux sociaux retirent les métadonnées. LinkedIn réencode les images, ce qui supprime le chunk PNG iTXt, et probablement aussi la pièce jointe PDF d'un carrousel. Déposer un fichier sur la page de vérification ne marche qu'avec le fichier d'origine, le QR code est donc le chemin principal.
  • Un SVG modifié perd sa charge utile. Un éditeur qui ouvre une carte SVG et l'enregistre à nouveau réécrit ou supprime le plus souvent son élément <metadata>, et le fichier donne alors no_card_payload. Le QR code reste le moyen de la vérifier.
  • Un JPEG XL réenregistré perd sa charge utile, et LinkedIn ne le prend pas. Un éditeur ou un convertisseur qui réécrit une carte JPEG XL supprime le plus souvent sa boîte pscd, le fichier donne alors no_card_payload et le QR code reste le moyen de la vérifier. LinkedIn n'accepte pas les images JPEG XL : publiez le PNG.
  • Le conteneur JPEG XL a été vérifié avec deux décodeurs. Un test décode la carte avec jxl-rs et jxl-oxide. Chrome et Firefox décodent le JPEG XL avec jxl-rs, et jxl-oxide est un second décodeur indépendant qu'aucun navigateur ne livre. libjxl (djxl) n'a pas été vérifié.
  • Le QR code porte moins que le fichier. La charge utile compacte contient l'angle, la période, les chiffres imprimés en gros, la période d'avant la correction, le domaine, la clé et le jour. Elle ne porte ni les champs libres, ni le périmètre, ni l'équivalence, ni la ligne CO2 : seul le fichier d'origine les prouve.
  • Une image republiée prouve ses chiffres, pas ses pixels. Un réseau social réencode une image et retire ses métadonnées, si bien que seul le QR code survit, et il prouve les chiffres, jamais les pixels qui l'entourent. verify-card affiche ces chiffres (figures) et la page de vérification les montre : comparez-les avec l'image. Sur le fichier original, une seconde signature (content_sig) couvre le fichier lui-même, si bien qu'une vraie enveloppe copiée sur une image retouchée donne content_modified.
  • La place du chunk de carte avant les données d'image du PNG n'est pas signée. Le contenu laisse de côté le chunk iTXt de la carte, si bien que le déplacer parmi les chunks entre IHDR et le premier IDAT garde le même contenu. Rien de visible ne change : le PNG du moteur ne contient aucun autre chunk à cet endroit, un fichier qu'il a écrit n'a donc qu'une place pour lui, et dans un fichier qui porte d'autres chunks auxiliaires un lecteur d'images dessine à partir de IHDR, PLTE et IDAT seulement, jamais d'un chunk de texte. L'élément SVG et la boîte JPEG XL, eux, doivent se trouver là où le moteur les écrit, puisqu'un élément SVG déplacé pourrait changer ce que le fichier dessine.
  • Tout réenregistrement d'un fichier original le rend modifié. Un éditeur qui enregistre à nouveau le fichier, même sans changement visible, donne content_modified, no_card_payload quand il supprime l'enveloppe, ou malformed_input quand il compresse le bloc PNG ou déplace l'élément SVG ou la boîte JPEG XL.
  • Le niveau 1 prouve l'intégrité, pas l'identité du signataire. N'importe qui peut fabriquer une clé. Sans clé publiée par le domaine de la carte (niveau 2), une signature valide dit seulement que la carte n'a pas été modifiée après sa signature.
  • Le résolveur DNS-over-HTTPS voit la requête. verify-card --online envoie le domaine de la carte au résolveur (cloudflare-dns.com par défaut, ou --doh), avec l'adresse IP de l'appelant, et lit le /.well-known/perf-sentinel-key du domaine, qui voit donc cette IP.
  • Les octets du PNG, du SVG et du JPEG XL sont reproductibles par cible. La même entrée et la même clé donnent le même PNG, le même SVG et le même JPEG XL sur une cible de compilation. La rastérisation en virgule flottante et la mise à l'échelle des contours peuvent varier d'une architecture à l'autre, deux machines peuvent donc produire des octets différents pour la même carte. Les deux se vérifient.
  • Le texte d'une carte SVG est fait de contours. Son texte est dessiné en formes de glyphes : il ne se sélectionne pas et ne se recherche pas, et un lecteur d'écran n'en reçoit que le <title>.
  • Une histoire à seuil est étroite. Sa période d'avant la correction est celle de la trace d'où vient son chiffre, pas une période mesurée de tout l'endpoint. « Ne déclenche plus l'alerte » signifie qu'un jour après la correction, le daemon a vu des passages sous le seuil et aucune détection, pas que chaque passage reste sous le seuil.
  • Les barres anciennes d'une courbe sont vérifiées par le Hub. Les covered_days et restart_days signés ne couvrent que la période de la carte. La ligne de périmètre compte les jours de toutes les barres, mais la complétude des périodes antérieures est vérifiée par le Hub quand il construit la carte, elle n'est pas signée.
  • La courbe ne nomme aucune correction. Une carte courbe n'imprime pas de ligne « confirmée par la mesure », puisqu'elle montre une tendance, pas une correction.