perf sentinelperf sentineldocs
FRENGitHub
Documentation / 05 · GreenOps & carbon

Scoring GreenOps et conversion carbone

Ce sujet existe en version animée : voir les étages Gaspillage, Énergie et Carbone dans le synoptique →

Score d'intensité I/O (IIS)

La métrique centrale est le Score d'Intensité I/O (I/O Intensity Score) : le nombre d'opérations I/O générées par requête utilisateur pour un endpoint donné.

IIS(endpoint) = total_io_ops(endpoint) / invocation_count(endpoint)

Un endpoint appelé à travers 3 traces avec 18 opérations I/O au total a IIS = 18 / 3 = 6.0. Cela normalise les différents volumes de trafic : un endpoint à fort trafic avec 1000 invocations et 6000 opérations I/O a le même IIS (6.0) qu'un endpoint à faible trafic avec 3 invocations et 18 opérations.

Le dénominateur utilise .max(1) comme garde contre la division par zéro, bien que ce cas ne puisse pas se produire en pratique (un endpoint qui apparaît dans endpoint_stats a forcément été vu dans au moins une trace).

Algorithme de scoring : cinq étapes

Étape 1 : statistiques par endpoint

rust
for (trace_idx, trace) in traces.iter().enumerate() {
    for span in &trace.spans {
        total_io_ops += 1;
        let stats = endpoint_stats.entry(key).or_insert_with(|| EndpointStats {
            total_io_ops: 0,
            invocation_count: 0,
            last_seen_trace: usize::MAX,
        });
        stats.total_io_ops += 1;
        if stats.last_seen_trace != trace_idx {
            stats.invocation_count += 1;
            stats.last_seen_trace = trace_idx;
        }
    }
}

Passe unique avec sentinelle par trace : invocation_count est incrémenté la première fois qu'une paire (service, endpoint) est vue dans une trace donnée, puis last_seen_trace est positionné pour bloquer toute ré-incrémentation sur la même trace. Initialiser la sentinelle à usize::MAX (et non 0) garde l'index de trace 0 valide comme marqueur de "première rencontre". Cela évite une seconde passe get_mut sur un HashSet par trace (une sonde HashMap de moins par paire (trace, endpoint)).

EndpointStats<'a> avec service emprunté : le champ service emprunte &'a str depuis les événements span au lieu de cloner le String. Le clone ne se produit que plus tard lors de la construction des structs TopOffender pour la sortie. Cela évite un clone de String par endpoint unique dans la boucle interne.

Structure sous-jacente (HashMap + sort vs BTreeMap) : la map par endpoint est un HashMap finalisé par un unique sort_by pour la vue publique, et non un BTreeMap. Sous le régime d'accès de Perf Sentinel (beaucoup de spans par endpoint unique, K petit devant N), les mesures sur 1M de spans donnent systématiquement l'avantage à HashMap + sort :

Cardinalité endpointsSpansHashMap + sortBTreeMapRatio
161M15 ms19 ms1,24x
641M16 ms31 ms1,94x
2561M17 ms49 ms2,89x
10241M18 ms73 ms3,99x

Le tri gratuit à l'itération du BTreeMap est noyé par son surcoût O(log K) par insertion. Le tri terminal est O(K log K) sur K petit (20-90 µs sur toute la plage), négligeable à côté du volume d'insertions.

Étape 2 : dédup des I/O évitables

rust
// dedup_avoidable_io_ops_by_service : valeur = KeyOps, le plus grand
// finding N+1 et chaque finding redondant vu sur la clé
let mut dedup: HashMap<DedupKey<'_>, KeyOps<'_>> = HashMap::with_capacity(capacity);
for f in findings {
    if !f.finding_type.is_avoidable_io() {
        continue; // les findings lents ne sont pas évitables
    }
    dedup
        .entry((&f.trace_id, &f.pattern.template, &f.source_endpoint, f.grouping_identity()))
        .or_default()
        .add(f.pattern.occurrences.saturating_sub(1), f);
}
// Une seule passe sur l'ensemble dédoublonné alimente à la fois le total global
// (réparti sql / messaging selon le type retenu) et la map par service derrière
// `perf_sentinel_service_avoidable_io_ops_total`, les deux ne peuvent donc pas diverger.
let mut per_service: BTreeMap<(&str, &str), usize> = BTreeMap::new();
for &(avoidable, f) in dedup.values().flat_map(KeyOps::credited) {
    out.total += avoidable;
    let grouping = f.grouping_value().unwrap_or("");
    for (service, ops) in f.avoidable_by_service() {
        *per_service.entry((service, grouping)).or_insert(0) += ops;
    }
}

Pourquoi créditer via avoidable_by_service() ? Aucun détecteur ne met le service dans sa clé de groupe, donc un template partagé par deux services dans une même trace est un seul finding, détenu par le service du premier span. pattern.occurrences_by_service enregistre combien de spans chaque service a émis quand il y en a plus d'un, et la répartition du gagnant est créditée par service : chacun ses propres répétitions, le détenteur une de moins pour l'appel nécessaire. La somme reste occurrences - 1, les compteurs par service continuent donc de sommer au compteur global, et un service ne lit pas 0 % ou plus de 100 % sur le waste ratio par service du dashboard pour un template qu'il partage. Le détenteur est le premier span dans l'ordre d'ingestion, pas le plus ancien horodatage, limite connue.

Pourquoi inclure source_endpoint dans la clé ? Le même template SQL (ex. SELECT * FROM config WHERE key = ?) peut être appelé depuis deux endpoints différents dans la même trace. Les opérations évitables de chaque endpoint doivent être comptées indépendamment. Sans source_endpoint, max(5, 3) = 5 sous-compterait : le total correct est 5 + 3 = 8.

Pourquoi le plus grand du chiffre N+1 et de la somme des redondants ? Deux findings N+1 sur une même clé décrivent les mêmes spans, donc le plus grand les représente. Les findings redondants d'une même clé sont un même template appelé avec des paramètres différents, donc des ensembles de spans disjoints, et ils s'additionnent : 3 appels avec id = 1 et 4 avec id = 2 font 2 + 3 = 5 évitables, pas 3. Le détecteur redondant écarte un template que le détecteur N+1 a déjà signalé dans le même groupement, donc un finding N+1 et un finding redondant ne partagent plus de clé. Garder la plus grande des deux lectures reste sûr si cela arrivait, puisque l'ensemble N+1 inclurait alors le redondant.

Findings lents exclus : les requêtes lentes sont des opérations nécessaires qui se trouvent être lentes. Elles ont besoin d'optimisation (indexation, cache), pas d'élimination. Les inclure dans le ratio de gaspillage confondrait "I/O gaspillées" avec "I/O lentes".

Étape 3 : calcul de l'IIS par endpoint

rust
let iis_map: HashMap<&str, f64> = endpoint_stats.iter()
    .map(|(&ep, stats)| {
        let invocations = stats.invocation_count.max(1) as f64;
        (ep, stats.total_io_ops as f64 / invocations)
    })
    .collect();

La map IIS est calculée une seule fois et réutilisée pour l'enrichissement des findings (étape 4) et le classement des top offenders (étape 5).

Étape 4 : enrichir les findings

Chaque finding reçoit un GreenImpact :

rust
GreenImpact {
    estimated_extra_io_ops: if slow { 0 } else { occurrences - 1 },
    io_intensity_score: iis,
}

Étape 5 : top offenders

Triés par IIS décroissant, avec un ordre alphabétique en cas d'égalité pour une sortie déterministe :

rust
top_offenders.sort_by(|a, b| {
    b.io_intensity_score.partial_cmp(&a.io_intensity_score)
        .unwrap_or(Ordering::Equal)
        .then_with(|| a.endpoint.cmp(&b.endpoint))
});

partial_cmp avec unwrap_or(Equal) gère NaN de manière sûre, bien que NaN ne puisse pas se produire puisque le dénominateur est toujours >= 1.0.

Ratio de gaspillage I/O

waste_ratio = avoidable_io_ops / total_io_ops

Quand total_io_ops == 0, le ratio est 0.0 (pas NaN). C'est la fraction d'opérations I/O qui pourraient être éliminées en corrigeant les anti-patterns détectés. Cela s'aligne sur le composant Énergie du modèle SCI (ISO/IEC 21031:2024) de la Green Software Foundation : réduire les calculs inutiles réduit la consommation d'énergie.

Conversion carbone

Le pipeline de scoring résout deux dimensions indépendantes pour chaque span : l'énergie par opération (E) et l'intensité du réseau électrique (I). Chacune a sa propre chaîne de repli, de la source la plus précise jusqu'aux valeurs embarquées par défaut.

Alignement SCI v1.0

Perf Sentinel aligne son modèle carbone sur la spécification Software Carbon Intensity v1.0 (adoptée comme ISO/IEC 21031:2024), en cohérence avec la formulation "aligné SCI, directionnel" utilisée partout ailleurs dans le projet : le proxy I/O couvre une frontière logicielle partielle et le rapport publie le numérateur sur son propre schéma plutôt que le format de rapport SCI par R. La formule est :

SCI = ((E × I) + M) per R

Où :

  • E = énergie consommée par la charge de travail (kWh)
  • I = intensité carbone géographique du réseau (gCO₂eq/kWh)
  • M = émissions embarquées de fabrication matérielle, amorties
  • R = unité fonctionnelle (le dénominateur "par X")

Quelle révision du SCI, et pourquoi cela ne change pas le modèle. Il existe deux révisions. La v1.0 est le texte adopté sous ISO/IEC 21031:2024, et c'est celle sur laquelle ce document s'aligne. La v1.1 est une révision éditoriale GSF postérieure. D'après ses notes de version, elle renomme "location-based marginal carbon intensity" en "region-specific carbon intensity" et ajoute une définition de "carbon" comme terme générique pour toutes les émissions qui contribuent au réchauffement. Toujours d'après ces notes, elle reformule aussi les émissions opérationnelles en "multiply the electricity consumption of the hardware the software is running on by the region-specific carbon intensity". La formule ((E x I) + M) per R est inchangée, donc rien dans le scoring de Perf Sentinel ne dépend de ce choix.

Une clarification de la v1.1 étend l'intensité carbone région-spécifique pour distinguer le réseau électrique du hors-réseau, et souligne que c'est l'information géographique qui compte pour éliminer les émissions, à l'exclusion des mécanismes market-based. C'est déjà la position de Perf Sentinel, qui ne score qu'en géographique, ce qui explique aussi pourquoi Schéma avertit que le chiffre publié n'est pas la valeur Scope 2 market-based qu'ESRS exige par ailleurs.

Plus loin, ce document cite le texte GSF en v1.1.0 sur les données modélisées que la spécification autorise. La citation porte sur cette version parce qu'elle est vérifiée contre le texte GSF librement lisible, alors que la publication ISO est payante et n'a pas été consultée. Les affirmations issues de cette source sont attribuées au texte GSF, jamais à ISO/IEC 21031:2024.

Dans Perf Sentinel :

  • R = 1 trace : une requête utilisateur. Chaque trace corrélée est une unité fonctionnelle.
  • E = io_ops × ENERGY_PER_IO_OP_KWH : proxy à partir du compteur d'ops I/O.
  • I = lookup_region(region).intensity : depuis la table carbone embarquée.
  • M = traces.len() × embodied_per_request_gco2 : configurable, défaut 0,001 g/req.

Constante énergétique

rust
pub const ENERGY_PER_IO_OP_KWH: f64 = 0.000_000_1; // 0,1 mWh par opération I/O

C'est une approximation grossière d'ordre de grandeur, pas une valeur mesurée. 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. Le projet Cloud Carbon Footprint utilise une approche similaire d'estimation de l'énergie à partir de l'utilisation des ressources plutôt que d'une mesure directe.

La valeur doit être divulguée comme méthodologie selon les exigences SCI. Elle est documentée dans le code, dans Limites et ici.

Carbone embarqué (terme M)

rust
pub const DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2: f64 = 0.001;

Le défaut de 0.001 gCO₂/request est dérivé d'hypothèses typiques sur le cycle de vie d'un serveur :

  • Un serveur x86 moderne a une empreinte carbone embarquée de ~1000 kgCO₂eq sur un cycle de vie de 4 ans (sources : analyses de cycle de vie de l'API Boavizta, méthodologie Cloud Carbon Footprint).
  • 4 ans × 365 jours × 86400 secondes × 1 requête/sec ≈ 126 millions de requêtes amorties par serveur.
  • 1000 g par serveur / 126e6 requêtes ≈ 0,000008 gCO₂/req (8e-6 g) à 1 req/sec, montant à ~0,001 à des taux de requêtes plus bas ou pour du matériel plus gros ou moins amorti.

Le défaut 0.001 g/req est une borne supérieure conservatrice pour des serveurs microservices peu chargés. La méthodologie AWS Customer Carbon Footprint (2025) rapporte ~320 kgCO2eq/an pour un Dell R640, ce qui à des taux d'utilisation typiques donne 10-50 ugCO2/req, soit 10-20x en dessous de notre défaut. Les utilisateurs avec des données d'infrastructure mesurées devraient abaisser cette valeur via [green] embodied_carbon_per_request_gco2.

L'embarqué est indépendant de la région. Les émissions de fabrication matérielle ne varient pas selon le lieu de déploiement. Perf Sentinel émet le carbone embarqué inconditionnellement quand le scoring vert est activé, même quand aucune région ne se résout, pour que les utilisateurs voient au moins une estimation plancher.

Formule de conversion

Pour chaque bucket de région :

operational_region = io_ops_in_region × ENERGY_PER_IO_OP_KWH × carbon_intensity × PUE

Total opérationnel sur toutes les régions :

operational_gco2 = Σ operational_region

Embarqué :

embodied_gco2 = traces.len() × embodied_per_request_gco2

Point médian du CO₂ total, transport_gco2 et les termes d'hôte comptant zéro quand ils sont absents (voir "Termes d'hôte de base de données et de broker" ci-dessous) :

total.mid = operational_gco2 + embodied_gco2 + transport_gco2 + G_db + G_msg

CO₂ évitable (via ratio, voir "Évitable via ratio" ci-dessous) :

accounted_io_ops = total_io_ops - unknown_ops
avoidable.mid = operational_gco2 × (avoidable_io_ops / accounted_io_ops)

Le dénominateur accounted_io_ops exclut le bucket synthétique unknown pour que le ratio soit cohérent avec operational_gco2 (qui l'exclut aussi). Numérateur et dénominateur sur la même base comptable.

Intervalle d'incertitude (multiplicatif 2×, pas arithmétique ±50%) :

total.low  = total.mid × 0.5    // mid divisé par 2
total.high = total.mid × 2.0    // mid multiplié par 2
(idem pour avoidable.low / avoidable.high)

C'est un intervalle log-symétrique : la moyenne géométrique de low et high vaut mid. Le cadrage 2× correspond mieux à l'incertitude d'ordre de grandeur du modèle proxy I/O qu'une fenêtre symétrique ±50%. Voir "Cadrage de l'incertitude" ci-dessous.

Où :

  • carbon_intensity = gCO₂eq/kWh pour le réseau électrique de la région
  • PUE = Power Usage Effectiveness (facteur de surcoût du datacenter)

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

La spécification SCI v1.0 définit SCI = ((E × I) + M) / R, une intensité exprimée par unité fonctionnelle R. Perf Sentinel émet les deux vues : le numérateur de cette formule sommé sur toutes les traces analysées (co2.total), et l'intensité proprement dite (co2.sci_per_trace).

co2.total.mid = Σ operational_gco2 + embodied_gco2
              = (E × I) + M   (sommé sur les traces analysées, l'empreinte)

co2.sci_per_trace.mid = co2.total.mid / traces_analyzed
                      = le score SCI, avec R = 1 trace

co2.total est une empreinte (gCO₂eq absolus), co2.sci_per_trace est le score d'intensité par unité fonctionnelle. L'unité fonctionnelle est déclarée sur co2.functional_unit ("trace", qui correspond à l'unité fonctionnelle Transaction / lecture-ou-écriture base de données de la spec SCI). La spec SCI autorise l'intensité réseau moyenne pour I (elle exige le location-based, pas le market-based, et admet explicitement la moyenne), donc le chiffre est conforme SCI.

Pour étiqueter cette distinction sémantique au niveau des données, CarbonEstimate porte un champ methodology avec cinq valeurs possibles :

  • "sci_v1_numerator+transport" : utilisé sur co2.total. L'empreinte (E × I) + M + T sommée sur les traces, y compris quand T vaut zéro.
  • "sci_v1_numerator+transport+hosts" : utilisé à la place sur co2.total quand une source d'hôte de base de données ou de broker est configurée. La même empreinte plus les termes d'hôte comptés dans les totaux G_db + G_msg, zéro compris.
  • "sci_v1_intensity" : utilisé sur co2.sci_per_trace. L'intensité par R ((E × I) + M) / R, R = 1 trace.
  • "sci_v1_operational_ratio" : utilisé sur co2.avoidable. Le ratio global aveugle à la région operational × (avoidable/accounted), excluant le carbone embarqué.
  • "sci_v1_operational_ratio+hosts" : utilisé sur co2.avoidable sur ces mêmes fenêtres. Le même ratio plus chaque terme d'hôte compté dans les totaux multiplié par son propre ratio de gaspillage SQL ou messaging.

Les valeurs distinctes de total et avoidable signalent aux consommateurs en aval que les deux sont calculés différemment et ne doivent pas être comparés comme s'ils étaient des quantités homogènes.

Évitable via ratio (choix de design)

Calculer le CO₂ évitable de manière précise par région nécessiterait de propager la résolution de région à travers la phase de dédup des findings (qui agrège actuellement les ops I/O évitables globalement par (trace_id, template, source_endpoint, grouping)). C'est complexe et sujet aux erreurs.

À la place, Perf Sentinel calcule :

avoidable.mid = operational_gco2 × (avoidable_io_ops / accounted_io_ops)

Cela préserve l'échelle relative (une réduction de 50% du gaspillage donne une chute de 50% du CO₂ évitable) sans nécessiter d'attribution de région par finding. En contrepartie, quand les ops évitables sont concentrées dans une région à haute intensité, ce ratio sous-attribue légèrement les économies. La simplification est documentée comme limitation connue et étiquetée au niveau des données via methodology: "sci_v1_operational_ratio".

Le ratio compte des opérations, pas leur énergie. Le même ratio par comptage met à l'échelle co2.avoidable et les deux paliers de gaspillage de la divulgation (opérationnel et canonique). Un SELECT évitable pèse une opération bien que son poids de verbe proxy soit 0,5x, toutes les opérations d'un service partagent une même énergie par op sur un backend mesuré, et une requête lente pèse une opération puisque duration_us n'est jamais lu. Le carbone évitable est donc surestimé quand les opérations évitables coûtent moins que le mélange comptabilisé, et sous-estimé quand elles coûtent plus.

Le carbone embarqué est exclu de l'évitable. Vous ne pouvez pas faire disparaître le silicium fabriqué en corrigeant des requêtes N+1 : les émissions embarquées sont fixes par requête peu importe l'efficacité de l'application. L'estimation évitable ne considère que le terme opérationnel.

Termes d'hôte de base de données et de broker

Un chiffre de base de données ou de broker mesuré ou déclaré ([green.alumet.database], [green.database_static], [green.alumet.broker], [green.broker_static]) entre dans le numérateur comme termes d'hôte à côté du transport, G_db et G_msg, et ses kWh rejoignent energy_kwh. fold_host_terms dans score/mod.rs s'exécute une fois par fenêtre après la boucle des régions. Un chiffre entre quand son modèle n'est pas estimated, que son energy_gco2 a été converti et que co2 vaut Some, si bien que les périmètres d'énergie et de carbone concordent toujours. Il pose alors in_totals sur le chiffre et écrit co2.database_gco2 ou co2.messaging_gco2. total, avoidable et sci_per_trace sont remis à l'échelle une seule fois, en gardant l'encadrement 2x :

energy_kwh    = Σ énergie par service + D + B
total.mid     = operational_gco2 + embodied_gco2 + transport_gco2 + G_db + G_msg
avoidable.mid = operational_gco2 × (avoidable_io_ops / accounted_io_ops) + G_db × r_sql + G_msg × r_msg

r_sql et r_msg sont les ratios de gaspillage SQL et messaging : chaque hôte est chiffré au ratio des opérations qu'il sert, jamais au ratio de toutes les opérations. compute_disclosure_waste suit le même découpage : le réservoir applicatif prend le ratio de toutes les opérations, chaque bloc d'hôte compté dans les totaux ajoute ses propres parts canonique et opérationnelle, et les parts canoniques gardent le seuil figé dans le binaire. operational_gco2, regions[] et les maps par service restent purement applicatifs, puisqu'un hôte n'est jamais un service. Une fenêtre scorée avec une source d'hôte configurée porte sci_v1_numerator+transport+hosts sur total et sci_v1_operational_ratio+hosts sur avoidable, zéro compris, comme l'étiquette du transport. Le chiffre estimé est déjà une part de l'énergie des services et n'entre jamais une seconde fois.

La cellule live (publish_live_summary dans daemon/event_loop.rs) restaure les derniers chiffres de base de données et de broker via sticky_waste_figure, pour que /api/export/report ne clignote pas entre deux scrapes. Une copie restaurée appartient à un lot antérieur, dont la cellule courante ne porte pas les totaux, elle reçoit donc in_totals = false. perf_sentinel_scored_carbon_gco2_total et la jauge carbon_gco2 ajoutent GreenSummary::scored_carbon_gco2(), le carbone opérationnel des régions plus le carbone d'hôte compté dans les totaux, embarqué et transport exclus.

Résolution multi-région

Chaque span résout vers une région effective via une chaîne à 3 niveaux (la première correspondance l'emporte) :

  1. event.cloud_region : extrait de l'attribut de ressource OTel cloud.region (avec repli sur l'attribut de span pour les SDKs qui le mettent sur les spans individuels). Le plus autoritatif. Les valeurs sont assainies à la frontière d'ingestion : les chaînes invalides (non-ASCII-alphanumérique-tiret-underscore, longueur > 64 ou vides) sont silencieusement écartées.
  2. [green.service_regions][event.service.to_lowercase()] : surcharge config pour les environnements où OTel ne le fournit pas (ex. ingestion Jaeger / Zipkin). Insensible à la casse (le loader de config met les clés en minuscules).
  3. [green] default_region : repli global.

Les spans sans région résolvable atterrissent dans un bucket synthétique "unknown" : zéro contribution au CO₂ opérationnel. La ventilation regions[] montre tout de même le bucket pour que les utilisateurs voient les ops I/O orphelines (signal visible pour le diagnostic, avec des messages tracing::debug! détaillés disponibles via RUST_LOG=debug).

Plafond de cardinalité des régions. Le BTreeMap par région est plafonné à 256 régions distinctes en une passe de scoring (constante MAX_REGIONS). Les chaînes de région excédentaires tombent dans le bucket unknown, empêchant l'épuisement mémoire depuis des attributs OTLP cloud.region contrôlés par un attaquant ou mal configurés.

Scalaire CO₂ des TopOffender en mode multi-région. Quand le scoring multi-région est actif (soit [green.service_regions] est non-vide, soit un span porte cloud.region), le scalaire top_offenders[].co2_grams est mis à None pour tous. Le calculer depuis default_region uniquement serait incohérent avec la ventilation par région. Les utilisateurs doivent se fier à green_summary.regions[] pour l'attribution par région dans les déploiements multi-région.

Cadrage de l'incertitude : multiplicatif 2×, pas ±50%

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

rust
pub struct CarbonEstimate {
    pub low: f64,           // mid × 0,5
    pub mid: f64,           // meilleure estimation
    pub high: f64,          // mid × 2,0
    pub model: &'static str,       // "io_proxy_v1"
    pub methodology: &'static str, // "sci_v1_numerator+transport[+hosts]", "sci_v1_intensity" ou "sci_v1_operational_ratio[+hosts]"
}

Les facteurs 0.5 et 2.0 encodent un intervalle d'incertitude multiplicative 2× autour du point médian :

geometric_mean(low, high) = sqrt(low × high) = sqrt(mid² × 0.5 × 2.0) = mid

C'est un intervalle log-symétrique : le mid est le centre géométrique, pas le centre arithmétique. L'écart entre low et high est un facteur 4 (high/low = 4), plus large qu'une fenêtre symétrique ±50% (qui donnerait high/low = 3).

Pourquoi 2× et pas ±50% ? Le modèle proxy I/O a une incertitude d'ordre de grandeur à chaque étape :

  • ENERGY_PER_IO_OP_KWH = 0.1 mWh/op est une approximation d'ordre de grandeur.
  • Les valeurs d'intensité réseau de CCF/Electricity Maps sont des moyennes annuelles, alors que l'intensité en temps réel varie 2-3× sur une journée.
  • Les PUE sont des moyennes par fournisseur, alors que les datacenters individuels varient.
  • Le carbone embarqué suppose une valeur conservatrice de cycle de vie serveur qui peut être décalée d'un ordre de grandeur pour du matériel spécifique.

Une fenêtre symétrique ±50% (high = 1,5 × mid) sous-estimerait cette incertitude. Le cadrage multiplicatif 2× affirme que 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. Le modèle n'a pas assez de résolution pour distinguer la précision par endpoint.

Versionnement du modèle

Le champ model versionne la méthodologie d'estimation (io_proxy_v1 pour le proxy annuel constant, io_proxy_v2 et io_proxy_v3 pour les profils horaires de carbone, une étiquette mesurée comme scaphandre_rapl pour RAPL), permettant aux consommateurs en aval de tracer quelle méthodologie a produit un rapport donné.

Recherche par région

La table d'intensité carbone est embarquée comme tableau statique et convertie en HashMap via LazyLock :

rust
static REGION_MAP: LazyLock<HashMap<&'static str, (f64, Provider)>> =
    LazyLock::new(|| CARBON_TABLE.iter().map(...).collect());

Pourquoi LazyLock<HashMap> au lieu d'un scan linéaire ? L'implémentation originale parcourait les 55 entrées à chaque appel. Avec le HashMap, la recherche est O(1). Le coût d'initialisation est payé une seule fois au premier accès.

Recherche insensible à la casse : la fonction publique lookup_region() convertit l'entrée en minuscules via to_ascii_lowercase() avant la recherche. Toutes les clés de la table sont stockées en minuscules. L'étape de scoring multi-région utilise un BTreeMap<String, usize> (pas HashMap) pour répartir les ops I/O par région résolue. Cela garantit un ordre d'itération déterministe et des sommes flottantes stables entre exécutions.

Valeurs PUE

FournisseurPUESource
AWS1,15AWS Cloud sustainability (flotte mondiale 2024)
GCP1,09Google data centers efficiency (moyenne annuelle flotte 2024)
Azure1,17Microsoft datacenter efficiency (FY25, juillet 2024 à juin 2025, owned-and-controlled)
OVHcloud1,24OVHcloud KPIs FY25 (moyenne groupe, septembre 2024 à août 2025). Mesuré sur douze mois et audité par Apave selon l'ISO 30134-2, sur 88% de la flotte détenue et exploitée. Les valeurs par datacenter vont de 1,19 (Strasbourg, Erith, Limburg) à 1,36 (Vint Hill). La table porte le chiffre groupe comme pour les trois hyperscalers
Scaleway1,375Scaleway Impact Report 2025 (moyenne 2024 sur dix datacenters : quatre en France, trois aux Pays-Bas, trois en Pologne). Les valeurs par datacenter vont de 1,24 (WAW2) à 1,50 (WAW1, WAW3)
OUTSCALE1,5Aucun PUE publié. 3DS OUTSCALE loue de la capacité au lieu d'exploiter ses propres datacenters, il n'existe donc aucun chiffre de flotte à citer et ses régions retombent sur l'a priori générique. Son partenaire Thésée annonce un PUE de conception de 1,2, qui n'est ni une valeur mesurée ni la flotte d'OUTSCALE, et le chiffre de 1,64 qui circule remonte à une infographie de 2021 qui ne l'énonce pas
Générique1,5Uptime Institute Global Data Center Survey 2025 (PUE annuel moyen pondéré de 1,54, stable depuis six années consécutives : 1,58 en 2023, 1,56 en 2024). Le seau générique couvre l'auto-hébergement, la colocation et les régions hors hyperscalers, la moyenne de l'enquête sectorielle est donc l'a priori défendable. Les régions hyperscalers portent leur propre PUE fournisseur

Le PUE (Power Usage Effectiveness) mesure le ratio entre l'énergie totale du datacenter et l'énergie de l'équipement IT. Un PUE de 1,15 signifie 15% de surcoût pour le refroidissement, l'éclairage et l'infrastructure. La moyenne de l'industrie est ~1,58 (Uptime Institute), et les fournisseurs cloud hyperscale atteignent des valeurs significativement plus basses, le 1,09 de GCP passant sous le plancher symbolique des 10% de surcoût.

La table ne porte qu'un PUE par fournisseur, pas par datacenter, même là où le fournisseur publie le grain plus fin. L'écart de 1,19 à 1,36 chez OVHcloud représente 8% autour de sa moyenne groupe, largement dans la fourchette de 2x que porte l'estimation entière : une colonne par région ajouterait une surface de maintenance sans déplacer un chiffre que quiconque lit.

Données d'intensité carbone

Les intensités carbone régionales du réseau électrique (gCO2eq/kWh) des réseaux nationaux proviennent des données annuelles Ember (base génération, dernière année par pays), régénérées dans carbon_data.rs par scripts/refresh-carbon-data.py et datées par CARBON_TABLE_VINTAGE. Les lignes subnationales (Amérique du Nord, Brésil BR-CS) restent maintenues à la main dans MANUAL_CARBON_ROWS depuis des sources zonales en base consommation. Méthodologie décrit la cadence de rafraîchissement et l'écart de comptabilité. La table couvre 17 régions AWS, 12 régions GCP, 6 régions Azure, 24 clés OVHcloud, 13 clés Scaleway, 5 clés OUTSCALE et 20 codes pays ISO.

OVHcloud nomme un même datacenter de trois façons selon l'API (la région OpenStack Public Cloud GRA11, le code de zone GRA, la chaîne de localisation S3 gra), plusieurs clés peuvent donc pointer le même réseau. Les clés OUTSCALE portent un préfixe outscale- parce qu'OUTSCALE réutilise les identifiants de région d'AWS pour d'autres lieux : son eu-west-2 est Paris là où celui d'AWS est Londres, soit un facteur cinq sur la même chaîne. Un déploiement OUTSCALE déclare donc la clé préfixée dans [green] default_region ou [green.service_regions].

Quand la région configurée n'est pas trouvée dans la table, les champs CO2 sont omis du rapport (aucune valeur par défaut n'est inventée).

Profils horaires d'intensité carbone

La valeur annuelle plate par région écarte la variance diurne qui peut être importante dans les réseaux avec une forte part de renouvelables variables ou de forts pics de demande. Pour capturer cette variance, Perf Sentinel embarque des profils horaires UTC du réseau électrique pour 22 régions. Quatre régions phares aux formes diurnes bien documentées portent un profil mensuel x horaire complet. Les 18 autres portent chacune leur propre profil représentatif 24 heures unique (voir plus bas) :

  • France (eu-west-3) : baseload nucléaire, forme plate-avec-pic-soir.
  • Allemagne (eu-central-1) : charbon + gaz + renouvelables variables, pics matin/soir prononcés.
  • Royaume-Uni (eu-west-2) : éolien + gaz, pics jumeaux modérés.
  • US-East (us-east-1) : gaz + charbon, plateau diurne 13h-18h UTC (9h-14h heure Est).

La moyenne arithmétique de chaque profil approxime la valeur annuelle plate correspondante dans les ±5%, préservant la continuité méthodologique : activer les profils horaires ne devrait pas provoquer de saut brutal du CO₂ rapporté pour un run sur une journée représentative. Le profil Allemagne (eu-central-1) violait historiquement cet invariant (moyenne ~431 gCO₂/kWh, figée au niveau de la crise charbon 2022, contre 338 en annuel). Depuis 0.8.7 il est recalibré sur le niveau Electricity Maps 2024 (~341) et l'invariant tient pour toutes les régions sans exception. Les utilisateurs qui ont besoin d'une calibration exacte peuvent désactiver les profils horaires avec use_hourly_profiles = false.

Sources : rapports open-data annuels Electricity Maps (formes diurnes typiques 2023-2024 par zone), ENTSO-E Transparency Platform (composition du réseau européen et courbes de demande), données quotidiennes RTE eco2mix (France), Fraunhofer ISE Energy-Charts (Allemagne), NGESO carbonintensity.org.uk (Royaume-Uni), EIA hourly generation data (US-East).

Quatre régions (eu-west-3, eu-central-1, eu-west-2, us-east-1) embarquent des profils mensuels x horaires complets (12x24, MONTHLY_PROFILES), rapportés sous le tag de modèle io_proxy_v3. Les autres régions profilées utilisent un seul profil représentatif de 24 heures (FLAT_YEAR_PROFILES) : pour elles, les données saisonnières additionnelles apporteraient un gain de précision marginal par rapport au coût de maintenance. Le tag IntensitySource distingue annuel, horaire et mensuel par région, promouvoir une région plate vers le mensuel reste donc rétrocompatible.

Le chemin de scoring parcourt chaque span une fois et aiguille entre trois sources d'intensité :

rust
let intensity_used = if ctx.use_hourly_profiles
    && hourly_profile_for_region_lower(region).is_some()
    && let Some(hour) = time::parse_utc_hour(&span.event.timestamp)
{
    lookup_hourly_intensity_lower(region, hour).unwrap_or(annual_intensity)
} else {
    annual_intensity
};

Quand l'aiguillage sélectionne le chemin horaire pour une région, la ligne RegionBreakdown est étiquetée intensity_source: "hourly" et le CarbonEstimate.model de niveau supérieur passe de "io_proxy_v1" à "io_proxy_v2". Si le même rapport contient des régions passées par le chemin plat, ces régions restent étiquetées intensity_source: "annual" tandis que le modèle de niveau supérieur lit toujours "io_proxy_v2". Le tag enregistre "le modèle le plus précis utilisé quelque part dans le run".

Auto-cohérence des lignes de ventilation. L'identité co2_gco2 ≈ io_ops × grid_intensity_gco2_kwh × pue × ENERGY_PER_IO_OP_KWH ne tient que dans le cas proxy (pas de snapshot Scaphandre/cloud). Quand de l'énergie mesurée est présente et que des services dans la même région utilisent des coefficients différents, le grid_intensity_gco2_kwh affiché reste l'intensité moyenne pondérée par les ops, mais l'énergie par op varie selon le service, ce qui rend l'identité approximative.

Les horodatages doivent être en UTC. parse_utc_hour rejette les formes d'offset non-UTC (+02:00, -05:00) plutôt que de les décaler silencieusement, parce que le profil embarqué est ancré en UTC. Les spans dont l'horodatage ne peut pas être parsé se rabattent sur l'intensité annuelle plate pour la région.

Invariant somme-puis-divise (défense contre la dérive dedup). Un helper unique compute_operational_gco2(io_ops, intensity, pue) empêche la formule d'être réimplémentée de façon incohérente entre chemins, étendu avec un helper de plus bas niveau per_op_gco2(energy_kwh, intensity, pue) qui est la source unique de vérité pour la multiplication energy × intensity × pue. Les trois chemins (proxy, horaire, Scaphandre) passent par ce helper. Le helper global est implémenté comme io_ops × per_op_gco2(ENERGY_PER_IO_OP_KWH, intensity, pue).

Intégration énergétique par processus Scaphandre

Le modèle proxy utilise une constante fixe ENERGY_PER_IO_OP_KWH (0,1 mWh par op). C'est une approximation à deux ordres de grandeur près, et elle traite tous les services et toutes les formes de charge de la même façon. Perf Sentinel offre une prise en charge opt-in pour remplacer le proxy par un coefficient mesuré au niveau service dérivé des lectures de puissance par processus de Scaphandre.

Comment ça s'intègre dans l'architecture. Scaphandre est un processus externe installé par l'utilisateur. Perf Sentinel NE bundle PAS et NE fork PAS Scaphandre : il scrape l'endpoint Prometheus /metrics que Scaphandre expose déjà. Le module score/scaphandre/ possède :

  • ScaphandreConfig : parsé depuis [green.scaphandre] dans .perf-sentinel.toml.
  • ScaphandreState : adossé à ArcSwap<HashMap<String, ServiceEnergy>> pour des lectures sans verrou depuis le chemin de scoring. Le scraper construit un nouveau Arc<HashMap> à chaque scrape réussi et le substitue atomiquement. Les lecteurs font un seul load_full() pour obtenir leur propre référence Arc sans contention de verrou.
  • spawn_scraper() : une tâche tokio qui s'exécute toutes les scrape_interval_secs et met à jour l'état.
  • parse_scaphandre_metrics() : parseur de texte Prometheus sensible aux échappements. Itère par .chars() pour la sécurité UTF-8. Un chemin rapide évite toute allocation quand aucun échappement par backslash n'est présent dans les valeurs de labels. Gère les séquences \" et \\ à l'intérieur des blocs de labels.
  • OpsSnapshotDiff : un helper de snapshot-diff qui lit les compteurs d'ops par service depuis MetricsState::service_io_ops_total et calcule le delta depuis le scrape précédent.
  • apply_scrape() : applique les lectures de puissance parsées + les deltas d'ops à l'état selon la formule ci-dessous.

La formule. Pour chaque service mappé dans une fenêtre de scrape :

power_watts       = process_power_microwatts / 1_000_000
joules            = power_watts × scrape_interval_secs
kwh               = joules / 3_600_000
energy_per_op_kwh = kwh / ops_observed_in_window

Quand ops_observed_in_window == 0, l'entrée d'état existante est conservée inchangée plutôt qu'effacée, ce qui évite l'oscillation du tag de modèle pour les services inactifs. Le seuil de péremption (3× l'intervalle de scrape) protège contre les scrapers bloqués.

Où le coefficient se branche. Le daemon prend un snapshot synchrone de toutes les sources d'énergie au début de chaque tick process_traces via build_tick_ctx. Cette map fusionnée est attachée à CarbonContext.energy_snapshot pour la durée du tick. Chaque EnergyEntry porte le coefficient et un tag de modèle ("scaphandre_rapl" ou "cloud_specpower"). Dans la boucle de spans de compute_carbon_report, l'énergie par op est résolue comme suit :

rust
let (energy_kwh, measured_model) = match &ctx.energy_snapshot {
    Some(snapshot) => match snapshot.get(&span.event.service) {
        Some(entry) => (entry.energy_per_op_kwh, Some(entry.model_tag)),
        None => (ENERGY_PER_IO_OP_KWH, None),
    },
    None => (ENERGY_PER_IO_OP_KWH, None),
};
let op_co2 = per_op_gco2(energy_kwh, intensity_used, pue);

L'étape de scoring suit des flags par région (any_alumet, any_scaphandre, any_kepler_ebpf, any_redfish_bmc, any_cloud_specpower, any_realtime) et le CarbonEstimate.model de niveau supérieur reflète la source la plus précise utilisée : "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, +cal est ajouté. Toutes les sources d'énergie se composent naturellement avec les profils horaires : une op avec énergie mesurée en eu-west-3 à 3h du matin UTC utilise l'énergie mesurée ET l'intensité horaire simultanément.

Compteur d'ops par service comme source unique de vérité. Le scraper lit le compteur d'ops par service depuis MetricsState::service_io_ops_total (un CounterVec Prometheus étiqueté par service et, depuis 0.19.0, par grouping) via snapshot_service_io_ops(), qui replie l'axe de regroupement en un seul total par service. Le chemin d'ingestion d'événements du daemon incrémente ce compteur à chaque événement normalisé. Utiliser directement le compteur Prometheus, plutôt qu'un compteur parallèle qu'il faudrait remettre à zéro à chaque fenêtre de scrape, évite les situations de concurrence lors de la remise à zéro et donne gratuitement aux utilisateurs Grafana un graphe de débit d'ops par service.

Arrêt propre. Le daemon capture le JoinHandle du scraper et appelle .abort() sur lui avant le drain process_traces final dans la branche Ctrl-C. Cela empêche les lignes de log "scrape failed" d'apparaître après le message "Shutting down daemon".

Ce que Scaphandre ne fait PAS. Voir la section Limites de précision Scaphandre dans Limites pour la discussion complète. Scaphandre donne des coefficients par service, pas d'attribution par finding. Deux findings N+1 dans la même JVM pendant la même fenêtre de scrape partagent le même coefficient par construction, car RAPL est au niveau processus, pas au niveau span.

Estimation d'énergie cloud (CPU% + SPECpower)

Pour les VMs cloud (AWS, GCP, Azure) qui n'exposent pas Intel RAPL aux guests, Perf Sentinel offre une voie alternative d'estimation d'énergie basée sur les métriques d'utilisation CPU et le modèle SPECpower. Le module se trouve dans score/cloud_energy/ et reproduit la structure du module Scaphandre.

Architecture. Le répertoire cloud_energy/ contient :

  • config.rs : CloudEnergyConfig et ServiceCloudConfig par service (provider, instance_type ou surcharges des watts idle/max, cpu_query optionnelle). cpu_query_groups regroupe les services par requête CPU résolue, espaces de bord retirés.
  • table.rs : table de correspondance embarquée avec les valeurs idle et max watts pour chaque type listé dans Types d'instance, après la mise à jour CCF du 2026-04-24. Toutes les entrées suivent une méthodologie unique homogène : idle_watts = vCPU * idle_per_vCPU et max_watts = vCPU * max_per_vCPU, avec les coefficients tirés par fournisseur de ccf-coefficients 2026-04-24 (coefficients-{aws,gcp,azure}-use.csv). Aucun surcoût de carte mère n'est reconstruit : la colonne baseboard AWS a été abandonnée par CCF en 2026-04-24 et n'est pas réajoutée. La règle des 5 pour cent répartit les entrées modernes en deux groupes. Les entrées sont ré-alignées sur CCF quand le calcul SPECpower direct divergeait (Sapphire Rapids sur AWS m7i/c7i/r7i et GCP c3, EPYC Genoa sur AWS m7a/c7a et GCP c3d/n2d, Graviton 3 m7g/c7g et Graviton 4 m8g/c8g mappés sur le proxy CCF EPYC 2nd Gen, EPYC Turin sur AWS m8a/c8a, Emerald Rapids sur GCP c4). Elles restent sur le calcul SPECpower_ssj 2008 direct 2024 Q1 - 2026 Q2 quand elles sont dans les 5 pour cent ou absentes du CSV du fournisseur (AWS Milan m6a/c6a, Turin GCP c4d, Ampere Altra GCP t2a, Sapphire Rapids Azure, Emerald Rapids Azure, Genoa Azure, Cobalt 100 Azure, Sierra Forest). Nouvelles familles AWS ajoutées par cette mise à jour : m8a / c8a (Turin), m8i / c8i (Emerald Rapids), r7a (Genoa memory-optimized). Nouvelle famille GCP : c4a (Axion ARM Neoverse V2, proxié sur AWS Graviton 4). Des lignes ultérieures ajoutent Graviton 2 (m6g/c6g/r6g/t4g) et Graviton optimisé mémoire (r7g/r8g), et GCP n1 et Azure Standard_B*, qui tournent sur plusieurs générations de processeurs, prennent celle dont le max W/vCPU publié par le CSV CCF est le plus élevé (voir Limites). resolve_instance_class ramène les noms de classes managées de base de données, de cache, de broker et de recherche et les noms AWS ou Azure à modificateur sur la ligne de leur famille de base, ou sur une raison Unsupported, et lookup_instance_power garde le défaut du fournisseur pour tout le reste. Voir Limites.
  • scraper.rs : scraper API JSON Prometheus. Envoie chaque requête CPU résolue distincte telle qu'écrite, sans gabarit, une fois par tick, et lit la valeur de la première série et le nombre de séries.
  • state.rs : CloudEnergyState adossé à ArcSwap pour des lectures sans verrou depuis le chemin de scoring.
  • mod.rs : ré-exports et documentation du module.

La formule. Pour chaque requête CPU distincte dont la réponse contient une seule série, lue par des services qui se résolvent sur une même enveloppe (idle_watts, max_watts) :

cpu_percent       = prometheus_query(cpu_query)
watts             = idle_watts + (max_watts - idle_watts) * (cpu_percent / 100)
joules            = watts * scrape_interval_secs
kwh               = joules / 3_600_000
energy_per_op_kwh = kwh / sum(ops_in_window des services qui lisent la requête)

Chaque service du groupe qui a des ops dans la fenêtre reçoit cet energy_per_op_kwh, donc le nœud n'est compté qu'une fois. Une réponse à plusieurs séries, ou un groupe dont les services se résolvent sur des enveloppes différentes, garde le calcul par service : chaque service applique sa propre enveloppe à la première série et divise par ses propres ops. Le cas à plusieurs séries avertit une fois par requête, les enveloppes différentes avertissent au chargement de la configuration. idle_watts et max_watts viennent de la recherche dans la table SPECpower par type d'instance ou des surcharges fournies par l'utilisateur dans la configuration. Le nombre d'ops vient du même compteur MetricsState::service_io_ops_total que celui utilisé par Scaphandre.

Exemple de configuration.

toml
[green.cloud]
prometheus_endpoint = "http://prometheus:9090"
scrape_interval_secs = 15
default_provider = "aws"
default_instance_type = "c5.xlarge"

[green.cloud.services.api-us]
provider = "aws"
instance_type = "m7i.4xlarge"  # Sapphire Rapids, entrée moderne
cpu_query = 'aws_ec2_cpuutilization_average{instance_id="i-0123456789abcdef0"}'

[green.cloud.services.api-eu]
provider = "gcp"
instance_type = "c4d-standard-8"  # AMD Turin (Zen 5), entrée moderne
cpu_query = '100 - (avg(rate(node_cpu_seconds_total{mode="idle",instance="api-eu:9100"}[5m])) * 100)'

Tag de modèle et précédence. Le coefficient porte le tag "cloud_specpower". Dans build_tick_ctx, les sources de plus haute fidélité priment : Alumet écrase Scaphandre, qui écrase Kepler, qui écrase Redfish, qui écrase cloud SPECpower pour un même service. Le tag de modèle de niveau supérieur reflète la source la plus précise : 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.

Daemon uniquement. Comme Scaphandre, l'estimation d'énergie cloud est une fonctionnalité daemon uniquement. La commande analyze batch utilise toujours le modèle proxy.

Ce que cloud SPECpower ne fait PAS. Voir Limites "Limites de précision du cloud SPECpower" pour la discussion complète. Le modèle SPECpower capture la puissance proportionnelle au CPU mais pas la mémoire, les I/O ou le réseau. Des services d'un même nœud ne se partagent ses watts que s'ils lisent une même requête à une seule série, et les locataires non tracés sont attribués aux services tracés. La précision est d'environ +/-30%.

Attribution de l'énergie par intervalle d'Alumet

L'intégration [green.alumet] scrape le plugin de sortie prometheus-exporter d'Alumet et suit le même patron d'état partagé que toutes les autres sources mesurées (AgedEnergyMap sur ArcSwap, seuil de fraîcheur à 3 × scrape_interval, OpsSnapshotDiff par service). Deux choses la rendent structurellement différente.

Une troisième forme de relevé. Scaphandre exporte une jauge de microwatts instantanée, Kepler un compteur cumulatif monotone en joules, Alumet ni l'un ni l'autre. Son exporteur publie chaque mesure comme une jauge Prometheus contenant la dernière valeur flushée, et rapl_consumed_energy est déclarée CounterDiff : les joules consommés pendant un poll_interval de la source (défaut amont 1s, flush toutes les 5s). La valeur est donc un delta d'intervalle republié tel quel entre deux flushes. Ni sommer entre les scrapes (double comptage à l'intérieur d'une fenêtre de flush, intervalles perdus d'une fenêtre à l'autre) ni faire un delta contre le scrape précédent (la valeur est déjà un delta) n'est correct. Le scraper divise par l'energy_interval_secs déclaré par l'opérateur pour retrouver des watts moyens, puis intègre sur sa propre fenêtre de scrape :

watts             = joules_per_interval / energy_interval_secs
window_joules     = watts × scrape_interval_secs
energy_per_op_kwh = window_joules / (ops × 3_600_000)

Passée la division, c'est la formule de Scaphandre mot pour mot, et elle hérite de la même hypothèse de stationnarité. Cela veut aussi dire qu'alumet/apply.rs ne porte aucun état entre les ticks : chaque scrape se suffit, et un redémarrage de l'exporteur n'a besoin d'aucune protection contre la remise à zéro d'un compteur.

energy_interval_secs est invérifiable. L'intervalle n'apparaît nulle part dans l'exposition, et aucun signal à l'exécution ne distingue une déclaration correcte d'une fausse. Un écart avec le poll_interval côté Alumet met l'énergie et le carbone à une échelle linéairement fausse, en silence, tout en étiquetant le résultat measured. L'autodétection a été envisagée (observer la jauge et chronométrer ses changements pour déduire la période de flush) puis rejetée : elle déduit le flush_interval, pas le poll_interval, et une heuristique maligne mais fragile vaut moins qu'un couplage de configuration documenté pour une valeur que l'opérateur a déjà sous les yeux. La mitigation est triple : le champ est affiché dans la ligne de log Alumet scraper started, Configuration montre les deux fichiers côte à côte, et Limites le nomme comme le plus gros risque de justesse pour une série RAPL. Un compteur cumulatif ou l'exposition de l'intervalle côté amont retirerait le champ.

Les joules sont supposés, les noms sont vérifiés. Le scraper lit chaque valeur comme des joules. En v0.9.5, l'exporteur ne publie aucune unité pour une métrique qui en a une (ni suffixe de nom, ni ligne # UNIT), et energy-attribution déclare des joules quoi que calcule sa formule, donc le fil ne peut pas trancher l'unité. Une source non RAPL arriverait dans la mauvaise unité (énergie GPU et Grace en millijoules, puissance Jetson en milliwatts) et sous l'étiquette alumet_rapl, d'où la restriction de l'intégration aux séries issues de RAPL. is_non_rapl_series dans alumet/scraper.rs appuie cette règle par un avertissement au démarrage du scraper, sur les noms de sources non RAPL connus et sur un suffixe d'unité autre que le joule, la forme que la branche de développement d'Alumet donne aux noms après la v0.9.5. Aucun réglage d'unité n'a été ajouté : il corrigerait l'ordre de grandeur et publierait quand même l'énergie GPU comme alumet_rapl.

Entrées de parseur fournies par l'opérateur. Le nom de métrique et la clé de label de Kepler sont épinglés par l'énumération KeplerMetricKind parce que l'amont fixe les deux. Son filtre zone est laissé à l'opérateur parce que les noms de zone dépendent du compteur que lit Kepler (domaines RAPL ou capteurs hwmon). Alumet n'en fixe aucun : l'exporteur applique un prefix/suffix configurable (suffixe par défaut _alumet) à chaque nom, et la série par service vient d'une formule energy-attribution que l'opérateur nomme. Donc metric_name et label_key sont des champs de configuration obligatoires sans défaut, validés contre les caractères de contrôle et la longueur dans validate_alumet, et rejetés bruyamment au chargement s'ils sont absents (validate_alumet_raw, appelé depuis load_from_str avant la conversion avec perte Config::from). Aucune de ces deux chaînes n'atteint jamais un label Prometheus. Elles sont comparées au corps scrapé, il n'y a donc aucune exposition à la cardinalité. Cette surface générique (métrique, label) est aussi ce qui permet à une seule forme de configuration de couvrir toutes les séries Alumet issues de RAPL : les pods k8s via name, les domaines RAPL bruts via domain, les PID procfs via resource_consumer_id.

Précédence. alumet_rapl est en tête de la chaîne mesurée, au-dessus de scaphandre_rapl. Les deux lisent RAPL, mais l'échantillonnage d'Alumet est mesurablement moins erroné (Raffin et al., <https://hal.science/hal-04420527v2/document>) et il attribue par cgroup plutôt que par processus. Comme tous les rangs de cette chaîne, il ordonne la fidélité d'attribution, pas la couverture au wattmètre (voir la note Redfish ci-dessous).

Parseur partagé. parse_metric_samples dans score/prom_parser.rs a été extrait de kepler/parser.rs quand Alumet en est devenu le deuxième consommateur : le code était déjà générique sur (metric_name, label_key). parse_metric_samples_where ajoute un filtre optionnel (label, valeur), utilisé par Kepler seul. scaphandre/parser.rs reste séparé parce qu'il extrait deux labels (exe, cmdline) en une seule passe, sa boucle de scan diverge donc légitimement.

Attribution de l'énergie du broker

Un broker pose le problème de la base de données en double : il brûle l'énergie d'une boucle de publication N+1, il n'émet aucun span à lui, et il est très souvent managé, donc il n'existe aucun hôte où faire tourner un agent de mesure. Perf Sentinel reprend la forme de database_waste avec le ratio messaging seul, broker energy × (avoidable publish ops / total publish ops), rapportée en green_summary.messaging_waste. Un chiffre mesuré ou déclaré est une énergie d'hôte de broker, qui entre dans les totaux comme terme d'hôte G_msg dès que son carbone est converti (voir "Termes d'hôte de base de données et de broker" plus haut). Sans aucune des deux sources, le chiffre prend la forme estimée : la part côté application des spans de publication, l'énergie que le scoring des spans a donnée aux services qui publient, déjà comprise dans les totaux et sans aucune énergie d'hôte de broker, si bien qu'il n'y entre jamais une seconde fois.

Pourquoi pas un coefficient par publication. Traité plus bas sous "Pourquoi aucun coefficient par publication ne peut être une mesure". La puissance d'un broker cesse de suivre le débit au-delà d'environ 20 % de sa capacité, l'énergie marginale n'est donc pas une constante, et les trois éléments qui la détermineraient (taux d'utilisation, facteur de réplication, topologie) sont invisibles depuis un span producteur. Le chiffre est une mesure au niveau du workload répartie par un ratio de comptage, jamais un coefficient.

Pourquoi cloud_energy n'était pas réutilisable. L'idée évidente est de faire passer le broker par le chemin CPU% et SPECpower existant. La voie par opération la bloque trois fois : ops_snapshot_diff ne produit aucun delta d'ops pour un workload qui n'émet pas de spans, cloud_energy/table.rs garde sur ops == 0, et la porte de région dans carbon_compute.rs n'est jamais atteinte. Seul le motif Alumet-base de données, qui contourne entièrement la boucle de spans, s'applique. lookup_instance_power est réutilisée directement, mais pas la machinerie autour.

Deux sources, un seul accumulateur. [green.alumet.broker] mesure le cgroup du broker, une seconde instance de DbEnergyState à côté de celle de la base. [green.broker_static] déclare un cluster provisionné et ne demande aucun agent, ce qui convient à un cluster provisionné avec une classe d'instance typée (un cluster Kafka ou MQ managé, des VM autogérées). Les brokers serverless ou facturés en unités de capacité n'ont aucun nœud provisionné et ne peuvent pas être déclarés. Son modèle est E(n) = n × P_max, résolu une fois au chargement de la configuration et non par lot.

Ce que borne ce chiffre déclaré est plus étroit qu'il n'y paraît. SPECpower couvre le CPU et la carte mère, il plafonne donc le calcul des vCPU déclarés et non la consommation murale du cluster, et un broker limité par le stockage peut consommer davantage. Il ne bouge pas non plus quand l'application se met à envoyer par lots, ce qui est une faiblesse pour un chiffre censé montrer qu'une remédiation fonctionne. MAX_BILLABLE_MS tronque une période d'inactivité à une heure, le chiffre déclaré sous-compte donc là. Les trois sont énoncés dans Limites.

L'arbitrage est la partie subtile. Une mesure prime sur une déclaration, mais "la mesure est vivante" n'est pas la question au niveau du tick qu'elle semble être.

  • Par intervalle, pas par tick. Alumet livre rétroactivement : un delta couvre chaque intervalle depuis le précédent, une série vivante possède donc aussi les trous entre ses deltas. Arbitrer sur "un delta est-il arrivé à ce tick" facture ces trous deux fois, une fois par étiquette de modèle.
  • La série, pas l'endpoint. mark_alive se déclenche à chaque scrape réussi, que le label du workload soit apparu ou non, ce qui est juste pour garder vivante l'énergie mise en banque. La possession demande le signal plus fort has_recent_sample, sinon un label_value mal orthographié laisse l'endpoint répondre indéfiniment et supprime la déclaration pour toujours, ne publiant plus rien du tout.
  • L'énergie en banque est réelle. Quand la série se tait, ce qu'elle a mis en banque tant qu'elle vivait est livré avant que la déclaration ne prenne la fenêtre. Un label jamais vu n'a rien en banque, ce qui laisse une faute de frappe retomber sur le repli.
  • La déclaration possède la période qu'elle a facturée. Du tick où un repli facture jusqu'à la reprise de la série, tout delta de la série qui couvre ce temps mural est jeté via discard_pending, sous condition d'un marqueur de panne porté par StaticBrokerState. Le marqueur permet d'appliquer ce rejet sans risque sur un tick périmé comme sur le tick de reprise. Il n'est posé qu'une fois qu'une déclaration a facturé, donc le premier tick périmé livre encore ce que la série a mis en banque tant qu'elle vivait. Jeter à chaque tick de repli sans tenir compte du marqueur effacerait ces joules.

Le chiffre porte sa provenance. DbEnergyContext.model est rempli par la source qui a peuplé la fenêtre, parce que rien en aval ne peut le déduire. Son Default est la plus faible des quatre étiquettes (estimated), pour qu'un appelant qui oublie de déclarer sa provenance ne puisse pas revendiquer une mesure. La divulgation périodique garde quatre seaux plutôt que deux : fondre un cluster déclaré dans measured_energy_kwh publierait la déclaration d'un opérateur sur du matériel provisionné comme une lecture du broker. Un chiffre cloud_specpower interpolé sur le CPU reste lui aussi hors de measured_energy_kwh, dans modeled_energy_kwh.

Attribution de l'énergie de la base de données

Le chiffre de la base a la forme de celui du broker avec le ratio SQL seul, et les deux mêmes sources. [green.alumet.database] mesure le cgroup de la base. [green.database_static] avec placement = "dedicated", nodes et instance_type déclare un cluster provisionné, facturé à E(n) = n × P_max par le même plafond StaticBrokerState sous l'étiquette database_specpower. Cela convient à une base managée avec une classe d'instance typée, dont le nom managé se résout sur sa famille de VM. Les bornes du plafond sur une base (un hôte peu chargé, des lecteurs et standbys comptés par l'opérateur, le stockage, les IOPS et le réseau managés laissés de côté) sont dans Limites.

Groupes d'hôtes. Une déclaration d'hôtes de tailles différentes liste des groupes [[...hosts]]. StaticHostsConfig::groups les transforme en déclarations à un seul groupe, et le daemon construit une sonde CPU et un plafond par groupe, appariés dans SubsystemSources, si bien que l'arbitrage entre lecture et déclaration ci-dessus s'applique groupe par groupe sans changement. take_host_energy additionne les parts lues et déclarées des groupes. Une fenêtre où un groupe a facturé sa déclaration prend l'étiquette déclarée pour tout le chiffre, les deux parts additionnées, si bien qu'un repli n'est jamais publié comme une lecture. Le scraper cloud interroge toutes les sondes à la fois, et la jauge d'âge de chaque sous-système lit sa sonde la plus ancienne. Les groupes sont refusés à côté de la charge Alumet, qui reste ainsi la source lue du groupe unique.

L'arbitrage est réutilisé, pas copié. take_broker_energy arbitre les deux sous-systèmes sans changement : une série vivante fraîche possède la chronologie et avance le marqueur du plafond sans facturer, une série périmée laisse le plafond facturer et poser le marqueur de panne, et le delta qui arrive à la reprise est jeté une fois. take_host_energy l'appelle pour un sous-système. Chaque propriété listée sous "L'arbitrage est la partie subtile" ci-dessus vaut pour la base comme pour le broker.

Un lot de sources par sous-système. EnergySources porte database et broker, chacun un SubsystemSources { live, live_staleness_ms, declared }. L'état vivant est la charge Alumet avec la fraîcheur Alumet, ou le DbEnergyState que le scraper cloud facture depuis la cpu_query de la section avec la fraîcheur cloud (3 × scrape_interval). La validation au chargement refuse une cpu_query à côté de la charge Alumet, un sous-système a donc au plus un état vivant. Chaque état vivant voyage avec sa propre fraîcheur, la lecture d'un sous-système n'est donc jamais filtrée par la fenêtre de fraîcheur de l'autre. Quand le scoring green est coupé, host_sources remet à la boucle deux lots vides, rien ne consomme donc une énergie mise de côté que score_green ne lirait jamais. La cellule live garde un chiffre d'hôte pendant deux fois la fraîcheur Alumet, ou deux fois la fraîcheur cloud quand une sonde CPU tourne, la plus longue des deux, durée portée à trace_ttl_ms quand l'un des sous-systèmes déclare un plafond, qui n'a aucun scraper pour relier des lots de moins d'une seconde.

Étiquettes de base. host_energy_context ne construit un contexte que pour un sous-système doté d'une source que le daemon facture, et son model est l'étiquette de base : alumet_rapl avec une charge Alumet, sinon cloud_specpower pour une section dotée d'une cpu_query, la source vivante que cette requête nomme (toute section partagée en a une, D5/B5), et l'étiquette déclarée pour un plafond sans elle. patch_host_energy garde l'étiquette de base quand une lecture vivante remplit la fenêtre et écrit l'étiquette déclarée (database_specpower, broker_specpower) quand c'est le plafond, un chiffre nomme donc la source de ses kWh. Une section partagée n'a pas de plafond, ses fenêtres portent donc toujours cloud_specpower et une sonde périmée ne facture rien. Une déclaration sans plafond ni cpu_query ne construit aucun contexte, et le chiffre estimé reste.

La sonde CPU. setup_cloud_scraper construit un HostProbe par section dotée d'une cpu_query, la base de données en premier. Une sonde dédiée (HostProbe::dedicated) a les nodes de la section et l'enveloppe node_power() de son type d'instance, une sonde partagée est décrite plus bas, et spawn_cloud_scraper les exécute sur les ticks cloud après les requêtes des services, qu'une lecture de service ait réussi ou non. fetch_host_cpu lance un seul fetch_cpu_percent : une réponse à plusieurs séries échoue en MultipleSeries, et un échec ou une valeur non finie ne facture rien, avertit une fois jusqu'à la prochaine lecture valide et fait avancer la gauge *_cpu_last_scrape_age_seconds du sous-système. bill_host_probes ajoute nodes × interpolate_watts(idle, max, cpu) × elapsed / 3_600_000 kWh à l'état de la sonde, elapsed étant les secondes écoulées depuis le tick précédent (l'intervalle, plus long quand un tick lent retarde le suivant, la même durée que couvre le delta d'opérations des services), avec add_window_kwh, qui marque aussi l'échantillon, et take_broker_energy arbitre donc la sonde exactement comme une série Alumet : après trois intervalles sans lecture le plafond facture la panne sous l'étiquette déclarée, et le premier delta après le retour est écarté.

La soustraction partagée. Une sonde partagée (HostProbe::shared) est un seul nœud. Son enveloppe est celle du groupe que liste shared_with, que D7/B7 tiennent à une seule enveloppe, sinon le node_power() de son type d'instance, et son group_key est la requête rognée de ce groupe, la clé du CloudGroup qui lit le nœud. Elle ne facture que sa part dynamique, (max - idle) × clamp(cpu)/100, l'idle du nœud étant déjà payé par ses services. Avec un shared_with vide, cette part est facturée et rien n'est déduit. Avec un groupe listé, elle demande une lecture de group_key à une seule série, sinon elle ne facture rien sur ce tick, et elle est plafonnée par un budget commun par groupe : budget_g = (max - idle) × clamp(c_g)/100 moins ce que les sondes précédentes ont pris sur ce groupe à ce tick, dans l'ordre des sondes, la base en premier, une base et un broker sur un même nœud ne facturent donc jamais ensemble plus que ses watts dynamiques. bill_host_probes tourne avant apply_cloud_scrape et renvoie les watts par requête de groupe. Pour un groupe partagé, apply_cloud_scrape publie P' = max(interpolate_watts(idle, max, c_g) - deductions[g], idle) et le répartit selon les opérations. Le repli par service (plusieurs séries, enveloppes mêlées) ne reçoit jamais de déduction, et un groupe listé sans lecture à une seule série ne facture rien à son hôte, un hôte n'est donc jamais facturé sans être retiré de son nœud.

Conservation. Le plafond donne P' >= idle par construction, le max est un garde-fou. Sur un tick où les services du groupe ont émis des opérations, Σ_services e × ops × 3.6e6 / interval + Σ_sondes sur g w = interpolate_watts(idle, max, c_g) : le nœud est compté une fois, la part des hôtes dans les termes d'hôte et le reste dans les coefficients des services. Sur un tick sans opérations, w est quand même facturé et le reste applicatif de ce tick n'est attribué à personne, les services gardant leurs lignes précédentes jusqu'à leur expiration.

La région ne bascule jamais. La region d'un contexte est la région effective du sous-système, GreenConfig::host_region : celle de la section statique, sinon celle de la charge Alumet, sinon default_region. La validation au chargement refuse une région statique et une région Alumet qui diffèrent, les sources vivante et déclarée d'un sous-système la partagent donc, et patch_host_energy n'y touche jamais. Un cluster est sur un seul réseau électrique quelle que soit la source qui a facturé la fenêtre, son intensité carbone ne change donc pas avec la source.

Notes d'attribution Kepler et Redfish

Les intégrations Kepler et Redfish suivent le même schéma d'état partagé que Scaphandre et cloud SPECpower (AgedEnergyMap adossé à ArcSwap, seuil de fraîcheur à 3 × scrape_interval, OpsSnapshotDiff par service) mais chacune porte des compromis méthodologiques qui méritent une note dédiée.

Une source, un seul compte, sur chaque backend. Chaque backend passe une source à energy_state::split_by_ops avec les services qui la lisent. Les opérations des services actifs pendant ce tick sont sommées, l'énergie de la source est divisée par cette somme, et chaque service actif reçoit la valeur energy_per_op obtenue, donc energy_per_op × ops retombe sur la lecture. La source est une valeur de label Kepler ou Alumet (regroupée par energy_state::services_by_key sur service_mappings), le seul processus Scaphandre qu'une règle de process_map sélectionne, un châssis Redfish, ou une requête CPU cloud renvoyant une seule série. Un service sans opération pendant ce tick garde sa ligne précédente. Une source lue par un seul service donne le même coefficient qu'avant la répartition.

Sémantique du delta de compteur Kepler. Kepler expose un compteur de joules cumulés monotone par conteneur ou processus, contrairement à la jauge de microwatts instantanée de Scaphandre. La tâche de scrape tient une HashMap<valeur de label, last_raw_joules>, un compteur par label quel que soit le nombre de services qui y sont associés, et calcule à chaque tick delta = current - previous, où current est la SOMME de tous les compteurs partageant la valeur de label mappée dans une même zone. Kepler expose une série par zone et les zones se recouvrent (package contient core), donc parse_metric_samples_where écarte les lignes dont le label zone diffère de [green.kepler] zone avant la somme. Les lignes sans label zone passent. Un nom de conteneur répété entre pods produit plusieurs séries, et une lecture dernier-écrit-gagne basculerait entre les compteurs au gré de l'ordre d'exposition. L'entrée n'est émise que si delta > 0.0 && delta.is_finite(). Ce filtre couvre les redémarrages de l'exporteur Kepler : le compteur se réinitialise à zéro, current < previous produit un delta négatif, et la garde le rejette. Les lectures non finies (NaN, ±Inf) sont également rejetées. Le scrape suivant produit le prochain delta significatif à partir de la nouvelle référence. La première observation par valeur de label (pas de previous) n'émet pas de delta. Le compteur brut est enregistré pour le scrape suivant.

Mode de scrape Kepler (direct vs Prometheus-médié). Kepler s'exécute en général comme DaemonSet Kubernetes (un pod par nœud). L'intégration [green.kepler] actuelle effectue un GET direct et requiert donc un endpoint qui expose les séries Kepler elles-mêmes, soit un exporteur local au nœud par Perf Sentinel, soit un endpoint de fédération/proxy exposant les séries agrégées. L'endpoint /metrics d'un serveur Prometheus ne convient pas. Un futur mode source = "prometheus" émettra des requêtes PromQL vers un Prometheus amont. L'enum metric_kind distingue déjà les séries Kepler à lire.

Formule d'attribution au niveau du nœud pour Redfish. Redfish expose une lecture de puissance par châssis, pas par service. Le scraper transforme cette lecture en coefficient énergie-par-opération par service via :

chassis_joules = chassis_watts × scrape_interval_secs
total_ops      = Σ ops_delta(service) for service ∈ mapped(chassis)
energy_per_op  = (chassis_joules / 3_600_000) / total_ops    (en kWh par opération)

Chaque service mappé au châssis reçoit la même valeur energy_per_op pour cette fenêtre de scrape. C'est l'interprétation correcte d'une puissance au niveau du nœud tant qu'aucun signal plus fin n'est disponible, et c'est documenté comme une granularité connue dans Limites "Limites de précision Redfish BMC". Les châssis inactifs (aucune opération mappée cette fenêtre) laissent l'entrée précédente de chaque service intacte, sans division par zéro et sans oscillation. Les lectures de wattage non finies, nulles, à zéro ou négatives sont rejetées comme états transitoires du BMC, et le coefficient précédent est préservé.

Limitation TLS Redfish. La plupart des BMCs présentent un certificat auto-signé par défaut. Le http_client::build_client partagé de Perf Sentinel s'appuie sur hyper-rustls avec les racines Mozilla plus les certificats PEM de SSL_CERT_FILE, donc un certificat auto-signé est rejeté s'il ne figure pas dans ce bundle. Le champ RedfishConfig::ca_bundle_path anticipe un bundle CA propre à chaque endpoint, mais son chargement PEM est reporté à une version ultérieure. Définir ca_bundle_path aujourd'hui amène le scraper à journaliser un ERROR et à refuser de démarrer. Cet échec explicite permet aux opérateurs avec un BMC auto-signé de voir la limite immédiatement plutôt qu'au milieu d'un handshake TLS loin de la configuration concernée. Un certificat BMC émis par une CA privée, ou un certificat auto-signé, est accepté dès qu'il figure dans SSL_CERT_FILE, à condition que ses noms alternatifs (SAN) couvrent l'hôte de l'endpoint. Un certificat auto-signé ne doit pas non plus être un certificat de CA : rustls-webpki rejette un certificat de CA présenté comme certificat serveur (CaUsedAsEndEntity). Sinon, les contournements dans la version courante sont un reverse proxy qui présente un certificat de confiance, ou HTTP sur un segment réseau de confiance.

Variance JSON entre fournisseurs pour Redfish. Les fournisseurs de BMC servent des formes légèrement différentes sous /redfish/v1/Chassis/{id}/Power, et certains ne la servent plus. Le schema legacy_power lit PowerConsumedWatts de l'entrée PowerControl dont le PhysicalContext est Chassis, sinon de l'entrée 0, car la DMTF n'exige pas que l'entrée 0 couvre tout le châssis. Le pointeur /PowerWatts/Reading d'environment_metrics et le pointeur /Reading de sensor sont fixés par schema, et une réponse sensor n'est lue que si son ReadingUnits vaut W et son PhysicalContext vaut Chassis ou est absent. v0.7.6 a retiré le champ power_path, les propriétés OEM des fournisseurs ne sont donc pas configurables. Le parseur rejette null, 0, les valeurs négatives et NaN comme invalides pour que les états transitoires du BMC ne polluent pas le coefficient.

Protection contre la limitation de débit Redfish. scrape_interval_secs est écrêté à [15, 3600] pour Redfish (contre [1, 3600] pour Scaphandre et Kepler). 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. Une requête limitée (HTTP 429) compte comme un scrape en échec. Valeur par défaut : 60 s.

Surface SSRF assumée. Les scrapers Kepler, Redfish, Scaphandre et cloud-energy acceptent tous de joindre une URL loopback ou RFC 1918 (http://127.0.0.1:9102/metrics, https://10.0.0.5/redfish/v1/...). Ces cibles sont attendues : Kepler s'exécute typiquement en DaemonSet sur le même nœud, les BMCs sont sur des réseaux d'administration, Scaphandre expose un endpoint Prometheus local. La validation à la lecture de la configuration refuse les URLs avec des identifiants embarqués (@) ou des caractères de contrôle, et le plafond de taille du corps dans http_client::fetch_get (8 Mio) borne la mémoire par requête. Le client legacy partagé de hyper-util est construit sans suivi de redirections, donc un endpoint malveillant ne peut pas rediriger le scraper (302) vers http://169.254.169.254/. La garantie au déploiement est que chaque URL que joint le daemon vient d'une configuration .perf-sentinel.toml fournie par l'opérateur, jamais dérivée d'une entrée externe (spans, réponses BMC, résultats de requêtes Prometheus).

Tags carbone à deux axes. La fidélité de l'énergie (E, classée par [carbon_compute::higher_fidelity_measured]) et la fidélité de l'intensité réseau (I, exposée par [region_breakdown::select_co2_model_tag]) sont des axes indépendants. Une même fenêtre peut porter co2.model = "electricity_maps_api" (l'intensité temps réel est la source I la plus précise) tandis que per_service_energy_model indique "scaphandre_rapl" pour le même service (RAPL est la source E la plus précise). Étiqueter le rapport selon la source I la plus précise pendant que la ventilation par service suit E permet aux auditeurs de voir les deux dimensions sans les fusionner dans un seul tag.

Nommer la source d'énergie à l'affichage. Quatre surfaces d'affichage nomment la source de E à partir de la couverture par service : trois surfaces terminal via GreenSummary::energy_source_label, et la carte Energy du dashboard avec les mêmes règles en JavaScript. Un service compte comme couvert dès que son per_service_measured_ratio dépasse zéro. Le libellé vaut source <tags> quand chaque service est entièrement couvert, source <tags> on N of M services · rest modeled from I/O counts quand les deux se mélangent, et modeled from I/O counts quand aucun ne l'est. La part modélisée est suivie de · calibrated quand GreenSummary.energy_calibrated est vrai (le drapeau de calibration de ReportFlags, que select_co2_model_tag écarte derrière un tag mesuré ou temps réel) ou qu'un tag porte +cal, pour les rapports écrits avant ce champ. Le energy_model de la fenêtre n'est jamais affiché comme source : il suit select_co2_model_tag, il vaut donc electricity_maps_api sur un daemon avec Electricity Maps, et io_proxy_vN désigne le palier d'intensité. Les tags hors de [A-Za-z0-9_+-]{1,64} sont écartés avant affichage, ce qui tient un rapport --input hostile hors du terminal et de la page. Ce sont l'onglet GreenOps du dashboard HTML (une carte Energy après Total CO2), le résumé terminal d'analyze, le panneau analyze d'inspect et l'onglet Energy de query monitor, dont la colonne source de la ventilation par service affiche I/O counts pour un service non couvert.

Intégration intensité temps réel Electricity Maps

Le bloc [green.electricity_maps] active le polling temps réel de l'intensité carbone du réseau électrique. Le scraper du daemon interroge périodiquement l'endpoint /carbon-intensity/latest d'Electricity Maps par zone et alimente le CarbonContext du tick courant, où la valeur prime sur les profils annuels et horaires pour les régions cloud mappées. Documenté à <https://app.electricitymaps.com/developer-hub/api/getting-started>.

Déduplication par zone. Le scraper itère sur region_map (cloud_region -> zone) mais une zone donnée n'est récupérée qu'une seule fois par tick, même si plusieurs cloud_region pointent dessus (montages multi-AZ classiques, ou aws:eu-west-3 et local-k3d tous deux épinglés sur FR). La lecture est ensuite distribuée à chaque cloud_region qui correspond. Le nombre d'appels API reste proportionnel au nombre de zones distinctes, pas à la taille de region_map. Cela compte sur les offres à quota contraint. Les offres commerciales se facturent par zone, et une même zone mappée depuis staging et prod ne coûte toujours qu'un appel par tick.

Métadonnées d'estimation. L'API Electricity Maps expose deux champs optionnels à côté de carbonIntensity :

json
{
  "zone": "FR",
  "carbonIntensity": 56.0,
  "isEstimated": true,
  "estimationMethod": "TIME_SLICER_AVERAGE"
}

isEstimated vaut true quand l'API a comblé un trou (zone Tier B/C, ou trou temporel comblé par un algorithme comme TIME_SLICER_AVERAGE), et false pour les valeurs entièrement mesurées. Perf Sentinel parse les deux champs avec #[serde(default)] pour rester compatible si une version future de l'API cesse de les émettre.

Les flags se propagent à travers IntensityReading (state) jusqu'au CarbonContext.real_time_intensity du tick puis jusqu'à l'accumulateur par région. La ligne green_summary.regions[] les expose comme deux champs optionnels :

json
{
  "status": "known",
  "region": "eu-west-3",
  "intensity_source": "real_time",
  "grid_intensity_gco2_kwh": 56.0,
  "intensity_estimated": true,
  "intensity_estimation_method": "TIME_SLICER_AVERAGE",
  "co2_gco2": 1.234
}

Les deux champs utilisent #[serde(skip_serializing_if = "Option::is_none")] pour que les consommateurs qui les ignorent continuent à désérialiser la ligne sans changement. Les champs n'apparaissent que quand intensity_source == "real_time". Les spans qui retombent sur les profils annuels ou horaires ne portent jamais les métadonnées, même si l'accumulateur les a capturées depuis un span voisin.

C'est le signal qu'un reporting Scope 2 attend pour distinguer les émissions mesurées des émissions modélisées. Les auditeurs admettent typiquement les valeurs estimées quand la méthodologie est documentée. Afficher le tag d'algorithme (TIME_SLICER_AVERAGE, GENERAL_PURPOSE_ZONE_DEVELOPMENT, etc.) rend la piste d'audit autonome.

Rendu utilisateur (0.5.10)

Les deux champs sont affichés dans les deux couches de rendu visibles par l'opérateur, qui voit la distinction d'un seul coup d'œil.

Dashboard. Le tableau Regions de l'onglet GreenOps gagne une sixième colonne Estimated. Trois états visuels : un badge orange Estimated quand intensity_estimated == true (le survol affiche une infobulle avec la intensity_estimation_method), un badge vert Measured quand intensity_estimated == false, et un tiret neutre pour les lignes dont intensity_source n'est pas real_time. Les profils annuels, horaires et mensuels-horaires ne portent pas de métadonnées d'estimation, donc le champ reste None de bout en bout. Les deux badges réutilisent les variables CSS de la palette existante (--color-background-warning, --color-text-warning, --color-background-success, --color-text-success) pour que les thèmes sombre et clair s'adaptent automatiquement.

Terminal. La ligne par-région de print_green_summary gagne un suffixe après le champ source: real_time. Format :

- fr: 42 I/O ops, 0.000123 gCO₂ (56 gCO₂/kWh, source: real_time, estimated/TIME_SLICER_AVERAGE)
- de: 24 I/O ops, 0.000456 gCO₂ (380 gCO₂/kWh, source: real_time, measured)
- us-east-1: 12 I/O ops, 0.000789 gCO₂ (410 gCO₂/kWh, source: annual)

Le suffixe est vide quand intensity_estimated est None, donc les scrapers de logs existants continuent à reconnaître la forme de ligne pre-0.5.10.

Version d'API (0.5.11)

Perf Sentinel cible l'endpoint Electricity Maps API v4 par défaut depuis 0.5.11. Les versions précédentes utilisaient v3 par défaut, qu'Electricity Maps continue à servir mais considère comme legacy. La migration a été déclenchée par la promotion de v4 en "latest" dans la référence d'API du developer hub (<https://app.electricitymaps.com/developer-hub/api/reference>) et constitue une protection anticipée contre le retrait à terme de v3.

Le schéma de réponse sur l'endpoint carbon-intensity/latest est identique à l'octet près entre v3 et v4, donc la migration est transparente pour les consommateurs en aval. Les lignes green_summary.regions[] sont inchangées quelle que soit la version d'API configurée, et le chemin de parsing utilise la même struct.

Rétro-compatibilité : les configs .perf-sentinel.toml existantes qui épinglent endpoint = "https://api.electricitymaps.com/v3" continuent à fonctionner. Le scraper détecte le chemin legacy au démarrage via ApiVersion::from_endpoint, qui reconnaît .../v3 en fin d'URL ou .../v3/... dans le chemin, avec une garde de limite de mot contre les faux positifs type /v30 ou /v300. Il émet ensuite un tracing::warn! une fois par démarrage du daemon, pointant l'opérateur vers la migration v4. Depuis 0.5.12, ApiVersion::from_endpoint est l'unique source de vérité, également consommée par le champ green_summary.scoring_config.api_version. La chaîne d'endpoint passe par sanitize_for_terminal avant d'être journalisée, pour qu'une TOML hostile ne puisse pas injecter d'octets de contrôle ANSI dans le flux de logs du daemon.

Transparence de la config de scoring (0.5.12)

L'objet green_summary.scoring_config expose la configuration runtime de l'intégration Electricity Maps pour qu'un auditeur ou l'auteur d'un rapport Scope 2 puisse voir quel modèle carbone a produit les chiffres sans lire la TOML de l'opérateur. Trois champs, tous dérivés d'ElectricityMapsConfig au chargement de la config via ScoringConfig::from_electricity_maps :

  • api_version : détecté à partir d'api_endpoint via ApiVersion::from_endpoint. Une de v3 (legacy), v4 (défaut), custom (proxy ou mock sans suffixe /vN).
  • emission_factor_type : miroir du réglage TOML, une de lifecycle (défaut) ou direct.
  • temporal_granularity : miroir du réglage TOML, une de hourly (défaut), 5_minutes, 15_minutes.

Périmètre de la surface. scoring_config a commencé (0.5.12) comme la configuration cliente Electricity Maps uniquement, une empreinte méthodologique partielle plutôt que le vecteur d'entrée SCI complet. La 0.9.25 y a ajouté les coefficients qui mettent les chiffres à l'échelle : embodied_per_request_gco2, network_energy_per_byte_kwh (la valeur fixe depuis la dépréciation de la clé), per_operation_coefficients et use_hourly_profiles. Elle a aussi ajouté un drapeau electricity_maps, puisque l'objet est désormais publié à chaque run scoré avec GreenOps et que sa seule présence ne signifie plus que l'API est configurée. Ce qui manque encore à un rejeu strict, c'est le PUE par région tiré de la table provider embarquée, récupérable seulement si la classification du provider est stable entre les runs. La divulgation republie les mêmes coefficients sous methodology.calibration_inputs.scoring_coefficients, agrégés depuis les fenêtres archivées plutôt que lus sur la machine qui lance disclose.

Rétro-compatibilité. Le champ vaut None (et le bandeau du dashboard / la ligne terminal sont masqués) quand [green.electricity_maps] n'est pas configuré, donc les rapports produits sans Electricity Maps gardent une forme identique au pre-0.5.12. La forme JSON est additive sur green_summary via #[serde(skip_serializing_if = "Option::is_none", default)], donc les baselines pre-0.5.12 réinjectées via report --before continuent à parser.

Câblage. Config::carbon_context() peuple CarbonContext::scoring_config: Option<ScoringConfig> à partir du green_electricity_maps chargé. score_green le lit depuis le contexte et le copie dans le GreenSummary résultant. Le build_tick_ctx par tick du daemon hérite du champ via le clone existant Cow::Owned(ctx), sans reconstruction par tick. Le pipeline batch CLI le récupère directement depuis le CarbonContext construit une seule fois.

Chemin snapshot du daemon. Depuis 0.5.13, /api/export/report sert un green_summary vivant rafraîchi par la boucle d'événements après chaque batch (régions, top offenders, ratio d'I/O évitables, chiffres CO2). scoring_config est ajouté par-dessus à partir du Config de démarrage du daemon, ce qui fait que la puce d'audit et l'onglet GreenOps apparaissent tous les deux dans le HTML rendu lorsqu'un opérateur fait passer le snapshot par perf-sentinel report --input -. La limitation 0.5.12 précédente (le snapshot retournait GreenSummary::disabled(0) et seul le champ scoring_config était renseigné, masquant l'onglet GreenOps) est levée.

Défense contre l'injection terminal : les trois champs sont des enums Rust typés à variants bornés, donc le rendu terminal dans print_green_summary n'a pas besoin de les envelopper dans sanitize_for_terminal (contrairement à intensity_estimation_method qui porte une String libre depuis le JSON --input). Le rendu HTML des puces utilise textContent (pas innerHTML) et setAttribute("title", ...), qui auto-échappent tous les deux.

Coefficients énergétiques par opération

Le modèle proxy utilise une seule constante ENERGY_PER_IO_OP_KWH (0,1 mWh) pour chaque opération I/O émise par le service émetteur. Cela traite un SELECT en lecture seule sur un index de la même manière qu'un INSERT gourmand en disque écrivant dans le WAL et les pages de données. Les coefficients par opération affinent cela en appliquant un multiplicateur selon le type d'opération. Ces multiplicateurs sont des poids relatifs sur le coût propre du service émetteur : ils classent les familles d'opérations et ne facturent aucun hôte de base de données.

Multiplicateurs SQL. Le verbe est extrait du premier mot du champ target (la requête SQL brute), pas du champ operation. C'est nécessaire car les spans ingérés via OTLP stockent db.system (ex. "postgresql") dans operation, pas le verbe SQL. Le premier token délimité par des espaces donne le verbe SQL de façon fiable dans tous les formats d'ingestion (JSON natif, OTLP, Jaeger, Zipkin).

Verbe SQLMultiplicateurJustification
SELECT0.5xRecherche d'index en lecture seule, pas d'écriture WAL
INSERT1.5xÉcriture WAL + page de données
UPDATE1.5xLecture + écriture
DELETE1.2xMarquage + WAL
Autre1.0xDDL, EXPLAIN, BEGIN, etc.

Paliers de taille de payload HTTP. Pour les spans HTTP, le multiplicateur dépend de response_size_bytes (extrait de l'attribut OTel http.response.body.size ou de l'attribut historique http.response_content_length).

Taille payloadMultiplicateurSeuil
Petit0.8x< 10 Ko
Moyen1.2x10 Ko à 1 Mo
Grand2.0x> 1 Mo
Inconnu1.0xattribut absent

Les spans messaging sont non pondérés (1.0x). Une publication ne reçoit pas les paliers HTTP, même si messaging.message.body.size est ingéré dans le même champ response_size_bytes. Les bornes des paliers encodent une distribution de charges utiles web : 1 Mo est le max.message.bytes par défaut de Kafka, un plafond protocolaire plutôt qu'un gros message, donc sous une configuration de broker par défaut la quasi-totalité des messages tomberait dans le palier 0.8x. Le résultat serait une remise quasi constante de 20 % plutôt qu'un modèle sensible à la taille. Aucune des trois sources ci-dessous n'a mesuré de broker, donc emprunter leurs ratios étendrait leur autorité à un domaine qu'elles n'ont jamais couvert. L'attribut de taille est ingéré pour qu'un coefficient mesuré puisse être calibré plus tard via calibrate contre un run Alumet sur un banc broker, le chemin qui a produit les ratios SQL.

Pourquoi aucun coefficient par publication ne peut être une mesure. La raison la plus forte n'est pas l'absence de chiffre publié, c'est que la puissance d'un broker ne suit pas son débit de messages. Une étude ICPE 2026 (ACM/SPEC) a mesuré Kafka et Redpanda avec Scaphandre sur RAPL (3 brokers, facteur de réplication 3, messages de 4 Kio, 60 partitions par broker, sur clusters NVMe, SSD et HDD). Elle rapporte que "Kafka exhibits a linear increase in power consumption up to about 20% of maximum throughput, after which it remains relatively stable regardless of additional load", en l'attribuant à des pools de threads fixes, contre environ 50 % pour Redpanda. L'énergie marginale d'une publication tend donc vers zéro au-delà de ce coude, et tout coefficient par opération est une allocation amortie d'une puissance essentiellement fixe, pas une mesure du travail marginal. Une étude au wattmètre plus ancienne pointe dans le même sens côté stockage : sur la charge Web de Filebench, XFS soutient beaucoup plus d'opérations qu'Ext2 pour seulement 29 % de puissance en plus, soit environ 6,75 fois moins d'énergie par opération à travail applicatif identique (Sehgal, Tarasov, Zadok, "Evaluating Performance and Energy in File System Server Workloads", USENIX FAST 2010). L'énergie par opération est dominée par la puissance fixe et par la pile logicielle, pas par le contenu de l'opération. Cela borne ce que le modèle proxy peut affirmer dans son ensemble, coefficients SQL et HTTP compris, et c'est pourquoi le coefficient messaging n'affirme rien.

Deux défauts de Kafka cassent aussi la lecture naïve "une publication égale une écriture durable", dans les deux sens. flush.messages vaut Long.MAX_VALUE par défaut et le projet lui-même déconseille de le régler ("we recommend you not set this and use replication for durability and allow the operating system's background flush capabilities as it is more efficient"), donc facturer un fsync par publication est faux. À l'inverse, la configuration durable typique (facteur de réplication 3, min.insync.replicas=2, acks=all) donne environ un append par réplique, et ce fan-out est piloté par replication.factor, pas par acks.

Assise normative. La spécification SCI de la Green Software Foundation (texte v1.1.0, et non la publication payante ISO/IEC 21031:2024, qui n'a pas été consultée) autorise une valeur modélisée : "In situations where there is a lack of access, capability, or rights to the necessary real-world data, the SCI allows for data generated through modeling, using best estimates instead." C'est un repli conditionné à l'inaccessibilité de la donnée, pas un choix libre, et aucune sous-spécification GSF ne couvre les charges de type broker ou messaging.

Sources. Les ratios relatifs proviennent de benchmarks académiques d'énergie SGBD (Z. Xu, Y.-C. Tu, X. Wang, "Exploring Power-Performance Tradeoffs in Database Systems", IEEE ICDE 2010, p. 485-496, Tsirogiannis, Harizopoulos, Shah, "Analyzing the Energy Efficiency of a Database Server", SIGMOD 2010, p. 231-242, Lella et al., "DBJoules: An Energy Measurement Tool for Database Management Systems", arXiv:2311.08961, 2023) et de la méthodologie Cloud Carbon Footprint, empruntés comme poids relatifs seulement : ces benchmarks ont mesuré des serveurs de base de données, alors que les poids mettent à l'échelle le coût par op propre au service émetteur et ne facturent aucun hôte de base de données. Les valeurs absolues sont des estimations d'ordre de grandeur. L'ordre relatif (SELECT < DELETE < INSERT/UPDATE) tient mieux d'une génération de matériel à l'autre.

Où cela s'intègre. Dans la boucle de spans de compute_carbon_report, le chemin proxy de repli applique le coefficient :

rust
let proxy_energy_kwh = if ctx.per_operation_coefficients {
    ENERGY_PER_IO_OP_KWH * energy_coefficient(&span.event)
} else {
    ENERGY_PER_IO_OP_KWH
};

Quand de l'énergie mesurée est disponible (n'importe quel backend mesuré), le coefficient n'est PAS appliqué : le backend mesuré remplace le proxy sur le même périmètre, l'hôte du service émetteur.

Détail hot path. La fonction energy_coefficient() est #[inline] et n'alloue pas : elle utilise split_ascii_whitespace().next() (lazy, s'arrête au premier espace) pour l'extraction du verbe et eq_ignore_ascii_case pour la comparaison au lieu d'une conversion en minuscules. Le verbe le plus courant (SELECT) correspond dès la première comparaison.

Interrupteur de configuration. [green] per_operation_coefficients = true (défaut). Passer à false pour utiliser la constante plate. Le tag de modèle reste io_proxy_v1 ou io_proxy_v2 quelle que soit la valeur de ce réglage. Les coefficients par opération sont un raffinement du modèle proxy, pas une nouvelle classe de modèle.

Énergie de transport réseau

Pour les appels HTTP inter-régions, le coût énergétique du transfert d'octets sur le backbone internet peut être significatif. Perf Sentinel offre un terme optionnel d'énergie de transport réseau.

La formule.

energy_transport_kwh = bytes_transferred * ENERGY_PER_BYTE_KWH
transport_co2        = energy_transport_kwh * source_region_intensity * source_pue

Le coefficient par défaut est 4e-11 kWh/byte (0.04 kWh/Go), un défaut prudent sous les moyennes réseau récentes. Le Sustainable Web Design Model v4 (2024) utilise 0.059 kWh/Go opérationnel (plus 0.013 incorporé) pour les réseaux, un chiffre qui inclut les réseaux d'accès que le trafic serveur inter-régions ne traverse pas. Les coefficients inter-datacenters descendent à 0.001 kWh/Go (coefficient réseau de Cloud Carbon Footprint, appliqué à des gigaoctets dans leur estimateur). Mytton, Lunden et Malmodin (2024, "Network energy use not directly proportional to data volume", Journal of Industrial Ecology 28(4):966-980, doi:10.1111/jiec.13512) démontrent que le modèle kWh/Go n'est valide que pour l'allocation rétrospective : les équipements réseau ont une puissance de base fixe significative, l'énergie n'augmente donc pas linéairement avec le volume (leur table 2 situe le modèle réseau fixe du Shift Project 2019 à 0.31-0.53 kWh/Go contre 0.07 mesuré chez Telefonica en 2020). Le défaut est donc une borne haute pour le trafic serveur inter-régions. Depuis la 0.9.25 le coefficient est fixe et non configurable, chaque divulgation met donc le transport à la même échelle, et la divulgation publie aussi le terme sous la fourchette sourcée : 0.001 kWh/Go en bas (Cloud Carbon Footprint) et 0.059 kWh/Go en haut (segment réseau du SWDM v4), via transport_kgco2eq_low / transport_kgco2eq_high.

L'intensité carbone et le PUE de la région source (celle d'où partent les données) sont utilisés, puisque l'infrastructure réseau qui sert la requête est co-localisée avec la source.

Détection inter-région. L'énergie de transport n'est calculée que quand l'appelant et l'appelé sont dans des régions différentes. Le mécanisme :

  1. Région appelant : résolue via la chaîne standard (span.cloud_region > service_regions[service] > default_region).
  2. Région appelé : le hostname est extrait de l'URL cible HTTP (ex. order-api depuis http://order-api:8080/api/orders), puis cherché dans ctx.service_regions. Si le hostname n'est pas mappé, Perf Sentinel suppose prudemment la même région (pas de terme de transport).
  3. Si les deux régions sont résolues et diffèrent (comparaison insensible à la casse), l'énergie de transport est calculée et cumulée.

Ce qui le déclenche. Deux conditions doivent être vraies à la fois pour qu'un span contribue à l'énergie de transport :

  • Le span est un appel HTTP sortant (event_type == HttpOut)
  • Le span a une valeur response_size_bytes (depuis l'attribut OTel http.response.body.size)

Sortie rapport. Le CO₂ transport apparaît comme transport_gco2 dans CarbonReport et GreenSummary. Il est inclus dans le total SCI : total_mid = operational + embodied + transport. Le champ est omis du JSON quand il vaut zéro, ce qui est le cas dès qu'aucun appel inter-région ne portait de taille de réponse.

Config. Plus aucune clé [green] ne conditionne le terme de transport. include_network_transport et network_energy_per_byte_kwh sont toutes deux dépréciées et ignorées depuis la 0.9.25, parsées seulement pour avertir : conditionner le calcul rendait deux rapports non comparables, un coefficient configurable laissait deux divulgations mettre le même trafic à des échelles différentes, un zéro effaçait le terme en silence, et un interrupteur d'affichage sur un chiffre toujours publié n'avait plus de justification.

Optimisations hot path. Le chemin transport s'exécute dans la boucle de scoring par span. Deux micro-optimisations évitent les allocations dans le cas courant :

  • Le hostname extrait de l'URL est comparé à service_regions avec un pattern probe-before-allocate : to_ascii_lowercase() n'est appelé que si le hostname contient des majuscules (rare pour les noms de service Kubernetes/Docker).
  • La région de l'appelant réutilise region_ref déjà résolu plus tôt dans la même itération de boucle au lieu d'appeler resolve_region une seconde fois.

Scalaire co2_grams des top offenders. Le co2_grams par offender utilise la constante plate ENERGY_PER_IO_OP_KWH, pas les coefficients par opération. Quand per_operation_coefficients est actif (le défaut), co2_grams est mis à None pour éviter une incohérence avec la ventilation par région. Le classement des top offenders (par IIS) n'est pas affecté puisque l'IIS compte des opérations, pas du CO2.

Limitations. Voir Limites "Énergie de transport réseau" pour la discussion complète : large fourchette d'estimation, aucun effet CDN, aucune modélisation de la compression, détection de région par configuration uniquement, aucune modélisation du dernier kilomètre.

Cohérence du cache d'état énergétique

Le scraper Scaphandre et le scraper SPECpower cloud publient tous les deux des lectures energy_per_op_kwh par service vers le chemin de scoring à chaque tick. Les deux états partagent un stockage ArcSwap dans crates/sentinel-core/src/score/energy_state.rs. Les deux types publics (ScaphandreState et CloudEnergyState) sont des wrappers newtype fins qui délèguent à AgedEnergyMap et conservent leur identité nominale pour un câblage typé à travers le daemon.

La conception privilégie les lectures fréquentes et les écritures rares :

  • Écritures : une fois par intervalle de scrape (5s par défaut pour Scaphandre, 15s pour cloud energy) par une seule tâche.
  • Lectures : une fois par tick process_traces (typiquement plusieurs par seconde sous charge OTLP réelle).
  • Cohérence : les lecteurs récupèrent l'Arc qui était courant au moment où ils ont appelé load_full, les écrivains ne bloquent personne.

ArcSwap a été choisi plutôt que RwLock<HashMap> parce que la lecture côté process_traces est sur la hot loop, et l'échange de pointeur via swap est wait-free contrairement à un RwLock qui bloque brièvement sur read() quand un écrivain tient le verrou.

Champ de confiance sur les findings (interop perf-lint planifié)

Un champ confidence est tamponné sur chaque Finding dans le rapport JSON et SARIF, indiquant le contexte source de la détection. La valeur est définie par l'appelant du pipeline : pipeline::analyze_with_traces pose CiBatch en mode batch quand il détecte un environnement de CI et LocalBatch sinon (Confidence::batch_for_ci), et daemon::process_traces la dérive de config.daemon.environment en mode streaming. Les détecteurs eux-mêmes ne raisonnent jamais sur la confiance : ils émettent Confidence::default() et l'appelant l'écrase.

Valeurs :

ConfidenceSourceRank SARIF
LocalBatchMode batch analyze, sans environnement de CI15
CiBatchMode batch analyze, environnement de CI détecté30
DaemonStagingDaemon watch avec [daemon] environment="staging"60
DaemonProductionDaemon watch avec [daemon] environment="production"90

Le champ apparaît dans :

  • Rapport JSON : chaque objet finding inclut "confidence": "local_batch" / "ci_batch" / "daemon_staging" / "daemon_production".
  • SARIF v2.1.0 : entrée de bag properties.confidence par résultat ET une valeur standard rank SARIF (0-100).
  • Sortie terminal CLI : NON affiché (le terminal reste propre pour l'usage interactif).

Le consommateur planifié est perf-lint, une intégration IDE compagnon (pas encore publiée), qui importera les findings runtime depuis la sortie JSON de Perf Sentinel et appliquera un multiplicateur de sévérité basé sur la confiance. Tout outil personnalisé qui consomme la même sortie JSON ou SARIF peut utiliser ce champ de la même manière. Voir Intégration "Champ de confiance sur les findings" pour l'exemple d'intégration.

Calibrer le proxy contre un vrai wattmètre

calibrate.rs sert la sous-commande calibrate, qui transforme un CSV de puissance mesurée en multiplicateurs par service pour le proxy I/O. Elle comble l'écart entre "un coefficient directionnel" et "un coefficient directionnel ancré sur votre matériel", sans exiger d'agent de mesure au runtime.

L'arithmétique est simple : pour chaque service présent à la fois dans les traces et dans les relevés, energy_per_op = total_energy / total_ops, et le facteur émis est ce rapport divisé par le coefficient proxy par défaut. Un service à zéro op sur la fenêtre d'observation est ignoré plutôt que doté d'un facteur, puisque diviser par lui fabriquerait un nombre à partir d'aucune preuve.

Les relevés ne doivent couvrir que la part de l'hôte propre du service. La puissance d'une base de données ou d'un broker relève de [green.alumet.database], [green.database_static], [green.alumet.broker] ou [green.broker_static], et l'intégrer au relevé d'un service la compte deux fois dès qu'une source d'hôte est déclarée.

Deux formes d'entrée sont acceptées et détectées automatiquement depuis l'en-tête CSV. timestamp,service,power_watts est intégré en énergie via l'intervalle entre relevés consécutifs par service, pour que des services entrelacés ne corrompent pas mutuellement leurs intervalles. timestamp,service,energy_kwh est pris tel quel. Les lignes de commentaire commençant par # sont ignorées, car les exports de wattmètre portent couramment un préambule.

Cela ne transforme pas le chiffre en mesure. Le résultat dépend toujours du ratio de comptage et garde une étiquette de modèle (io_proxy_*+cal) disant que c'est un modèle calibré et non une lecture. Une calibration dérivée sur un profil de charge et appliquée à un autre hérite des hypothèses du premier, raison pour laquelle le facteur est par service et non global.