perf sentinelperf sentineldocs
ENFRGitHub
Documentation / 05 · GreenOps & carbon

GreenOps scoring and carbon conversion

This topic exists in an animated version: see the waste, energy and carbon stages in the synoptic →

I/O Intensity Score (IIS)

The central metric is the I/O Intensity Score: the number of I/O operations generated per user request for a given endpoint.

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

An endpoint called across 3 traces with 18 total I/O operations has IIS = 18 / 3 = 6.0. This normalizes across different traffic volumes: a high-traffic endpoint with 1000 invocations and 6000 I/O ops has the same IIS (6.0) as a low-traffic one with 3 invocations and 18 ops.

The denominator uses .max(1) as a safety guard against division by zero, though this case cannot occur in practice (an endpoint that appears in endpoint_stats must have been seen in at least one trace).

Scoring algorithm: five steps

Step 1: endpoint statistics

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;
        }
    }
}

Single pass with per-trace sentinel: invocation_count is bumped the first time a (service, endpoint) pair is seen within a given trace, then last_seen_trace is set to block further bumps in that same trace. Initializing the sentinel to usize::MAX (rather than 0) keeps trace index 0 valid as a "first touch" marker. This avoids a second get_mut pass over a per-trace HashSet (one fewer HashMap probe per (trace, endpoint) pair).

EndpointStats<'a> with borrowed service: the service field borrows &'a str from the span events instead of cloning the String. The clone only happens later when building TopOffender structs for the output. This avoids one String clone per unique endpoint in the inner loop.

Backing structure (HashMap + sort vs BTreeMap): the per-endpoint map is a HashMap finalized with a single sort_by for the public view, not a BTreeMap. Under Perf Sentinel's access pattern (many spans per unique endpoint, small K relative to N), measurements on 1M spans showed HashMap + sort consistently faster:

Endpoint cardinalitySpansHashMap + sortBTreeMapRatio
161M15 ms19 ms1.24x
641M16 ms31 ms1.94x
2561M17 ms49 ms2.89x
10241M18 ms73 ms3.99x

BTreeMap's free-sort-on-iteration is dwarfed by its per-insert O(log K) overhead. The terminal sort is O(K log K) on small K (20-90 µs across the whole range), negligible next to the insert volume.

Step 2: dedup avoidable I/O

rust
// dedup_avoidable_io_ops_by_service: value = KeyOps, the largest N+1
// finding and every redundant one seen at the key
let mut dedup: HashMap<DedupKey<'_>, KeyOps<'_>> = HashMap::with_capacity(capacity);
for f in findings {
    if !f.finding_type.is_avoidable_io() {
        continue; // slow findings are not avoidable
    }
    dedup
        .entry((&f.trace_id, &f.pattern.template, &f.source_endpoint, f.grouping_identity()))
        .or_default()
        .add(f.pattern.occurrences.saturating_sub(1), f);
}
// One pass over the deduped set feeds both the global total (split sql /
// messaging by the retained type) and the per-service map behind
// `perf_sentinel_service_avoidable_io_ops_total`, so the two cannot diverge.
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;
    }
}

Why credit through avoidable_by_service()? Neither detector keys its group on the service, so a template shared by two services inside one trace is one finding, owned by the service of the first span. pattern.occurrences_by_service records how many spans each service issued when there is more than one, and the winner's split is credited per service: each its own repeats, the owner one less for the necessary call. The sum is still occurrences - 1, so the per-service counters keep adding up to the global one, and a service does not read 0 % or above 100 % on the dashboard's per-service waste ratio for a template it shares. The owner is the first span in ingestion order, not the earliest timestamp, a known ceiling.

Why include source_endpoint in the key? The same SQL template (e.g., SELECT * FROM config WHERE key = ?) may be called from two different endpoints in the same trace. Each endpoint's avoidable ops should be counted independently. Without source_endpoint, max(5, 3) = 5 would undercount. The correct total is 5 + 3 = 8.

Why the larger of the N+1 figure and the redundant sum? Two N+1 findings at one key describe the same spans, so the largest stands for them. Redundant findings at one key are one template called with different parameters, which are disjoint sets of spans, so they add up: 3 calls with id = 1 and 4 with id = 2 are 2 + 3 = 5 avoidable, not 3. The redundant detector skips a template the N+1 detector already reported in the same grouping, so an N+1 and a redundant finding no longer share a key. Keeping the larger of the two readings stays safe if they ever do, since the N+1 set would then include the redundant one.

Slow findings excluded: slow queries are necessary operations that happen to be slow. They need optimization (indexing, caching), not elimination. Including them in the waste ratio would conflate "wasteful I/O" with "slow I/O".

Step 3: compute IIS per 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();

The IIS map is computed once and reused for both finding enrichment (step 4) and top offender ranking (step 5).

Step 4: enrich findings

Each finding receives a GreenImpact:

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

Step 5: top offenders

Sorted by IIS descending, with alphabetical tiebreaker for deterministic output:

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 with unwrap_or(Equal) handles NaN safely, though NaN cannot occur since the denominator is always >= 1.0.

I/O waste ratio

waste_ratio = avoidable_io_ops / total_io_ops

When total_io_ops == 0, the ratio is 0.0 (not NaN). This is the fraction of I/O operations that could be eliminated by fixing detected anti-patterns. It aligns with the Energy component of the SCI model (ISO/IEC 21031:2024) from the Green Software Foundation: reducing unnecessary computation reduces energy consumption.

Carbon conversion

The scoring pipeline resolves two dimensions independently for every span: energy per op (E) and grid intensity (I). Both have fallback chains from the highest-fidelity source down to the embedded defaults.

SCI v1.0 alignment

Perf Sentinel aligns its carbon model with the Software Carbon Intensity v1.0 specification (adopted as ISO/IEC 21031:2024), matching the "SCI-aligned, directional" wording used everywhere else in the project: the I/O proxy covers a partial software boundary and the report publishes the numerator on its own schema rather than the per-R SCI reporting format. The formula is:

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

Where:

  • E = energy consumed by the workload (kWh)
  • I = location-based carbon intensity of the grid (gCO₂eq/kWh)
  • M = embodied emissions from hardware manufacturing, amortized
  • R = functional unit (the "per X" denominator)

Which SCI revision, and why it does not change the model. Two revisions exist. v1.0 is the text adopted as ISO/IEC 21031:2024, and it is the one this document aligns with. v1.1 is a later GSF editorial revision. Per its release notes, it renames "location-based marginal carbon intensity" to "region-specific carbon intensity" and adds a definition of "carbon" as a broad term for all warming-relevant emissions. Per the same notes, it also restates operational emissions as "multiply the electricity consumption of the hardware the software is running on by the region-specific carbon intensity". The ((E x I) + M) per R formula is untouched, so nothing in Perf Sentinel's scoring depends on the choice.

A v1.1 clarification expands region-specific carbon intensity to distinguish grid from off-grid, and stresses that location-based information is what matters for eliminating emissions, to the exclusion of market-based measures. Perf Sentinel already takes that position and scores location-based only, which is also why Schema warns that the disclosed figure is not the market-based Scope 2 value ESRS separately requires.

Elsewhere this document quotes the GSF text at v1.1.0 on what modelled data the specification permits. The quotation uses that version because it is checked against the freely readable GSF text, whereas the ISO publication is paywalled and was not consulted. Statements sourced that way are attributed to the GSF text, never to ISO/IEC 21031:2024.

In Perf Sentinel:

  • R = 1 trace: one user-facing request. Each correlated trace is one functional unit.
  • E = io_ops × ENERGY_PER_IO_OP_KWH: proxy from I/O op count.
  • I = lookup_region(region).intensity: from the embedded carbon table.
  • M = traces.len() × embodied_per_request_gco2: configurable, default 0.001 g/req.

Energy constant

rust
pub const ENERGY_PER_IO_OP_KWH: f64 = 0.000_000_1; // 0.1 mWh per I/O op

This is a rough order-of-magnitude approximation, not a measured value. Every rung of the ladder, the I/O proxy included, prices the emitting service's own host for each operation it issues: CPU, memory and network stack on that host. The callee (database server, broker, downstream service) is never in it. Database and broker host energy enters a report only through a declared or measured source: [green.alumet.database], [green.database_static], [green.alumet.broker] or [green.broker_static]. SQL verb weights and HTTP tiers are relative weights that rank operation classes. Their ratios come from database-server benchmarks, but they bill no database host. The Cloud Carbon Footprint project uses a similar approach of estimating energy from resource usage rather than direct measurement.

The value must be disclosed as methodology per SCI requirements. It is documented in the code, in Limitations and here.

Embodied carbon (M term)

rust
pub const DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2: f64 = 0.001;

The default of 0.001 gCO₂/request is derived from typical server lifecycle assumptions:

  • A modern x86 server has an embodied carbon footprint of ~1000 kgCO₂eq over a 4-year lifecycle (sources: Boavizta API lifecycle assessments, Cloud Carbon Footprint methodology).
  • 4 years × 365 days × 86400 seconds × 1 request/sec ≈ 126 million requests amortized per server.
  • 1000 g per server / 126e6 requests ≈ 0.000008 gCO₂/req (8e-6 g) at 1 req/sec, scaling to ~0.001 at lower request rates or larger / less amortized hardware.

The 0.001 g/req default is a conservative upper bound for lightly-loaded microservice servers. AWS Customer Carbon Footprint methodology (2025) reports ~320 kgCO2eq/year for a Dell R640, which at typical utilization rates yields 10-50 ugCO2/req, 10-20x below our default. Users with measured infrastructure data should lower this value via [green] embodied_carbon_per_request_gco2.

Embodied is region-independent. Hardware manufacturing emissions don't vary by deployment location. Perf Sentinel emits embodied carbon unconditionally when green scoring is enabled, even when no region resolves, so users see at least a floor estimate.

Conversion formula

For each region bucket:

operational_region = io_ops_in_region × ENERGY_PER_IO_OP_KWH × carbon_intensity × PUE

Total operational across all regions:

operational_gco2 = Σ operational_region

Embodied:

embodied_gco2 = traces.len() × embodied_per_request_gco2

Total CO₂ midpoint, transport_gco2 and the host terms counting zero when absent (see "Database and broker host terms" below):

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

Avoidable CO₂ (via ratio, see "Avoidable via ratio" below):

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

The denominator accounted_io_ops excludes the synthetic unknown bucket so the ratio is consistent with operational_gco2 (which also excludes it). This keeps the numerator and denominator on the same accounting basis.

Uncertainty bracket (2× multiplicative, not arithmetic ±50%):

total.low  = total.mid × 0.5    // mid divided by 2
total.high = total.mid × 2.0    // mid multiplied by 2
(same for avoidable.low / avoidable.high)

This is a log-symmetric interval: the geometric mean of low and high equals mid. The 2× framing matches the order-of-magnitude uncertainty of the I/O proxy model better than a symmetric ±50% window would. See "Uncertainty framing" below.

Where:

  • carbon_intensity = gCO₂eq/kWh for the region's electricity grid
  • PUE = Power Usage Effectiveness (datacenter overhead factor)

SCI v1.0 semantics: numerator vs intensity

The SCI v1.0 specification defines SCI = ((E × I) + M) / R, an intensity expressed per functional unit R. Perf Sentinel emits both views: the numerator of this formula summed over all analyzed traces (co2.total), and the intensity proper (co2.sci_per_trace).

co2.total.mid = Σ operational_gco2 + embodied_gco2
              = (E × I) + M   (summed over analyzed traces, the footprint)

co2.sci_per_trace.mid = co2.total.mid / traces_analyzed
                      = the SCI score, with R = 1 trace

co2.total is a footprint (absolute gCO₂eq), co2.sci_per_trace is the intensity score per functional unit. The functional unit is declared on co2.functional_unit ("trace", which maps to the SCI spec's Transaction / database read-or-write functional unit). The SCI spec permits average grid intensity for I (it requires location-based, not market-based, and explicitly allows the average), so the figure is SCI-conformant.

To tag this semantic distinction at the data layer, CarbonEstimate carries a methodology field with five possible values:

  • "sci_v1_numerator+transport": used on co2.total. The (E × I) + M + T footprint summed over traces, including when T is zero.
  • "sci_v1_numerator+transport+hosts": used on co2.total instead when a database or broker host source is configured. The same footprint plus the in-total host terms G_db + G_msg, zero included.
  • "sci_v1_intensity": used on co2.sci_per_trace. The per-R intensity ((E × I) + M) / R, R = 1 trace.
  • "sci_v1_operational_ratio": used on co2.avoidable. The region-blind global ratio operational × (avoidable/accounted), excluding embodied carbon.
  • "sci_v1_operational_ratio+hosts": used on co2.avoidable on the same windows. The same ratio plus each in-total host term times its own SQL or messaging waste ratio.

The distinct values on total and avoidable signal to downstream consumers that the two are computed differently and should not be compared as if they were homogeneous quantities.

Avoidable via ratio (design choice)

Computing avoidable CO₂ accurately per-region would require threading region resolution through the finding dedup phase (which currently aggregates avoidable I/O ops globally by (trace_id, template, source_endpoint, grouping)). This is complex and error-prone.

Instead, Perf Sentinel computes:

avoidable.mid = operational_gco2 × (avoidable_io_ops / accounted_io_ops)

This preserves the relative scale (a 50% waste reduction yields a 50% drop in avoidable CO₂) without requiring per-finding region attribution. The trade-off is that when avoidable ops are concentrated in a high-intensity region, this ratio slightly under-attributes the savings. The simplification is documented as a known limitation and tagged at the data layer via methodology: "sci_v1_operational_ratio".

The ratio counts operations, not their energy. The same op-count ratio scales co2.avoidable and both disclosure waste tiers (operational and canonical). An avoidable SELECT weighs one operation although its proxy verb weight is 0.5x, every operation of a service shares one energy per op on a measured backend, and a slow query weighs one operation because duration_us is never read. Avoidable carbon is therefore overstated when the avoidable operations are cheaper than the accounted mix, and understated when they are heavier.

Embodied carbon is excluded from avoidable. You can't optimize away manufactured silicon by fixing N+1 queries: embodied emissions are fixed per request regardless of how efficient the application is. The avoidable estimate only considers the operational term.

Database and broker host terms

A measured or declared database or broker figure ([green.alumet.database], [green.database_static], [green.alumet.broker], [green.broker_static]) enters the numerator as host terms beside transport, G_db and G_msg, and its kWh joins energy_kwh. fold_host_terms in score/mod.rs runs once per window after the region loop. A figure enters when its model is not estimated, its energy_gco2 converted and co2 is Some, so the energy and carbon perimeters always agree. It then sets in_totals on the figure and writes co2.database_gco2 or co2.messaging_gco2. total, avoidable and sci_per_trace are rescaled once, keeping the 2x bracket:

energy_kwh    = Σ per-service energy + 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 and r_msg are the SQL and messaging waste ratios, so each host is priced at the ratio of the operations it serves, never at the all-operations ratio. compute_disclosure_waste follows the same split: the application pool takes the all-operations ratio, each in-total host block adds its own canonical and operational legs, and the canonical legs keep the binary-pinned threshold. operational_gco2, regions[] and the per-service maps stay application-only, since a host is never a service. A window scored with a host source configured carries sci_v1_numerator+transport+hosts on total and sci_v1_operational_ratio+hosts on avoidable, zero included, like the transport tag. The estimated figure is already a share of the services' energy and never enters a second time.

The live cell (publish_live_summary in daemon/event_loop.rs) restores the last database and broker figures through sticky_waste_figure so /api/export/report does not flap between scrapes. A restored copy belongs to an earlier batch, whose totals the current cell does not hold, so it gets in_totals = false. perf_sentinel_scored_carbon_gco2_total and the carbon_gco2 gauge add GreenSummary::scored_carbon_gco2(), the regions' operational carbon plus the in-total host carbon, embodied and transport out.

Multi-region resolution

Each span resolves to an effective region via a 3-level chain (first match wins):

  1. event.cloud_region: extracted from the OTel cloud.region resource attribute (with span-level fallback for SDKs that put it on individual spans). Most authoritative. Values are sanitized at the ingest boundary: invalid region strings (non-ASCII-alphanumeric-dash-underscore, length > 64 or empty) are silently dropped.
  2. [green.service_regions][event.service.to_lowercase()]: config override for environments where OTel doesn't provide it (e.g. Jaeger / Zipkin ingestion). Case-insensitive (config loader lowercases keys).
  3. [green] default_region: global fallback.

Spans with no resolvable region land in a synthetic "unknown" bucket: zero operational CO₂ contribution. The regions[] breakdown still shows the bucket so users see the orphan I/O ops (the visible signal for troubleshooting, with detailed tracing::debug! messages available via RUST_LOG=debug).

Region cardinality cap. The per-region BTreeMap is capped at 256 distinct regions in one scoring pass (MAX_REGIONS constant). Excess distinct region strings fold into the unknown bucket, preventing memory exhaustion from attacker-controlled or misconfigured OTLP cloud.region attributes.

TopOffender scalar CO₂ in multi-region mode. When multi-region scoring is active (either [green.service_regions] is non-empty or any span carries cloud.region), the top_offenders[].co2_grams scalar is set to None across the board. Computing it from default_region only would be inconsistent with the per-region breakdown. Users should rely on green_summary.regions[] for per-region attribution in multi-region deployments.

Uncertainty framing: 2× multiplicative, not ±50%

Every CO₂ estimate is reported as { low, mid, high }:

rust
pub struct CarbonEstimate {
    pub low: f64,           // mid × 0.5
    pub mid: f64,           // best estimate
    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" or "sci_v1_operational_ratio[+hosts]"
}

The factors 0.5 and 2.0 encode a 2× multiplicative uncertainty bracket around the midpoint:

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

This is a log-symmetric interval: the mid is the geometric center, not the arithmetic center. The spread between low and high is a factor of 4 (high/low = 4), which is wider than a symmetric ±50% window (which would give high/low = 3).

Why 2× and not ±50%? The I/O proxy model has order-of-magnitude uncertainty at each step:

  • ENERGY_PER_IO_OP_KWH = 0.1 mWh/op is an order-of-magnitude approximation.
  • Grid intensity values from CCF/Electricity Maps are annual averages, while real-time intensity varies 2-3× over a day.
  • PUE values are provider averages, while individual datacenters vary.
  • Embodied carbon assumes a conservative server-lifecycle figure that may be off by an order of magnitude for specific hardware.

A symmetric ±50% window (giving high = 1.5 × mid) would understate this uncertainty. The 2× multiplicative framing states that the true value is within a factor of 2 of mid, in either direction.

The bounds reflect aggregate model uncertainty, not per-endpoint variance. The model doesn't have enough resolution to distinguish per-endpoint precision.

Model versioning

The model field versions the estimation methodology (io_proxy_v1 for the flat annual proxy, io_proxy_v2 and io_proxy_v3 for hourly carbon profiles, a measured tag such as scaphandre_rapl for RAPL), allowing downstream consumers to track which methodology produced a given report.

Region lookup

The carbon intensity table is embedded as a static array and converted to a HashMap via LazyLock:

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

Why LazyLock<HashMap> instead of a linear scan? The original implementation scanned all 55 entries on every call. With the HashMap, lookup is O(1). The initialization cost is paid once on first access.

Case-insensitive lookup: the public lookup_region() lowercases the input via to_ascii_lowercase() before lookup. All table keys are stored in lowercase. The multi-region scoring stage uses a BTreeMap<String, usize> (not HashMap) to bucket I/O ops per resolved region. This guarantees deterministic iteration order and stable floating-point sums across runs.

PUE values

ProviderPUESource
AWS1.15AWS Cloud sustainability (2024 global fleet)
GCP1.09Google data centers efficiency (2024 annual fleet average)
Azure1.17Microsoft datacenter efficiency (FY25, July 2024 to June 2025 owned-and-controlled)
OVHcloud1.24OVHcloud KPIs FY25 (group average, September 2024 to August 2025). Measured over twelve months and audited by Apave against ISO 30134-2, covering 88% of the owned-and-operated fleet. Per-datacenter values span 1.19 (Strasbourg, Erith, Limburg) to 1.36 (Vint Hill). The table carries the group figure, as the three hyperscalers do
Scaleway1.375Scaleway Impact Report 2025 (2024 average over ten datacenters: four in France, three in the Netherlands, three in Poland). Per-datacenter values span 1.24 (WAW2) to 1.50 (WAW1, WAW3)
OUTSCALE1.5No published PUE. 3DS OUTSCALE rents capacity rather than operating its own datacenters, so there is no fleet figure to cite and its regions fall back to the generic prior. Its partner Thesee advertises a 1.2 design PUE, which is neither a measured value nor OUTSCALE's own fleet, and the 1.64 figure that circulates traces back to a 2021 infographic that does not state it
Generic1.5Uptime Institute Global Data Center Survey 2025 (weighted average annual PUE 1.54, flat for six consecutive years: 1.58 in 2023, 1.56 in 2024). The Generic bucket covers self-hosted, colocation and non-hyperscaler regions, so the industry survey average is the defensible prior. Hyperscaler regions carry their own provider PUE

PUE (Power Usage Effectiveness) measures the ratio of total datacenter energy to IT equipment energy. A PUE of 1.15 means 15% overhead for cooling, lighting and infrastructure. The industry average is ~1.58 (Uptime Institute), and hyperscale cloud providers achieve significantly lower values, GCP's 1.09 being below the symbolic 10% overhead floor.

The table holds one PUE per provider, not per datacenter, even where the provider publishes the finer grain. OVHcloud's 1.19 to 1.36 spread is an 8% swing around its group average, well inside the 2x bracket the whole estimate carries, so a per-region column would add a maintenance surface without moving a figure anyone reads.

Carbon intensity data

Regional grid carbon intensities (gCO2eq/kWh) on national grids come from Ember yearly data (generation-based, latest year per country), regenerated into carbon_data.rs by scripts/refresh-carbon-data.py and stamped CARBON_TABLE_VINTAGE. Subnational rows (North America, Brazil BR-CS) stay hand-maintained in MANUAL_CARBON_ROWS from consumption-based zonal sources. Methodology records the refresh cadence and the accounting difference. The table covers 17 AWS regions, 12 GCP regions, 6 Azure regions, 24 OVHcloud keys, 13 Scaleway keys, 5 OUTSCALE keys and 20 ISO country codes.

OVHcloud names one datacenter three ways depending on the API (the OpenStack Public Cloud region GRA11, the zone code GRA, the S3 location string gra), so several keys can point at the same grid. OUTSCALE keys carry an outscale- prefix because OUTSCALE reuses AWS region identifiers for different places: its eu-west-2 is Paris where the AWS one is London, a factor of five on the same string. An OUTSCALE deployment therefore declares the prefixed key in [green] default_region or [green.service_regions].

When the configured region is not found in the table, CO2 fields are omitted from the report (no default value is invented).

Hourly carbon intensity profiles

The flat annual value per region discards the diurnal variance that can be large in grids with a high share of variable renewables or strong demand peaks. To capture that variance, Perf Sentinel embeds hourly UTC grid profiles for 22 regions. Four flagship regions with well-documented diurnal shapes carry a full monthly x hourly profile. The other 18 each carry their own single representative 24-hour profile (see below):

  • France (eu-west-3): nuclear baseload, flat-with-evening-peak shape.
  • Germany (eu-central-1): coal + gas + variable renewables, strong morning/evening peaks.
  • UK (eu-west-2): wind + gas, moderate twin peaks.
  • US-East (us-east-1): gas + coal, flat daytime peak 13h-18h UTC (9am-2pm ET).

Each profile's arithmetic mean approximates the corresponding flat annual value within ±5%, preserving methodology continuity: enabling hourly profiles should not cause a sudden jump in the reported CO₂ for a representative-day run. The Germany (eu-central-1) profile historically broke this invariant (mean ~431 gCO₂/kWh, frozen at the 2022 coal-crisis level, vs annual 338). Since 0.8.7 it is rescaled to the Electricity Maps 2024 level (~341) and the invariant holds for every region with no exception. Users needing exact calibration can disable hourly profiles with use_hourly_profiles = false.

Sources: Electricity Maps annual open-data reports (2023-2024 typical diurnal shapes by zone), ENTSO-E Transparency Platform (European grid composition and demand curves), RTE eco2mix daily data (France), Fraunhofer ISE Energy-Charts (Germany), NGESO carbonintensity.org.uk (UK), EIA hourly generation data (US-East).

Four regions (eu-west-3, eu-central-1, eu-west-2, us-east-1) embed full monthly x hourly profiles (12x24, MONTHLY_PROFILES), reported under the io_proxy_v3 model tag. The other profiled regions use a single representative 24-hour profile (FLAT_YEAR_PROFILES): for them the additional seasonal data would provide marginal accuracy gain compared to the maintenance cost. The IntensitySource tag distinguishes annual, hourly and monthly per region, so promoting a flat region to monthly later is backward-compatible.

The scoring path walks each span once and dispatches between three intensity sources:

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
};

When the dispatch selects the hourly path for a region, the region's RegionBreakdown row is tagged intensity_source: "hourly" and the top-level CarbonEstimate.model flips from "io_proxy_v1" to "io_proxy_v2". If the same report contains regions that went through the flat path, those regions stay tagged intensity_source: "annual" while the top-level model still reads "io_proxy_v2". The tag records "the most precise model used anywhere in the run".

Self-consistency of breakdown rows. The identity co2_gco2 ≈ io_ops × grid_intensity_gco2_kwh × pue × ENERGY_PER_IO_OP_KWH holds only in the proxy-energy case (no Scaphandre/cloud snapshot). When measured energy is present and services within the same region use different coefficients, the displayed grid_intensity_gco2_kwh is still the ops-weighted mean intensity, but the per-op energy varies per service, making the identity approximate.

Timestamps must be UTC. parse_utc_hour rejects non-UTC offset forms (+02:00, -05:00) rather than silently shifting them, because the embedded profile is UTC-anchored. Spans with unparseable timestamps fall back to the flat annual intensity for the region.

Sum-then-divide invariant (defence against dedup drift). A single compute_operational_gco2(io_ops, intensity, pue) helper prevents the formula from being re-implemented inconsistently across paths. This is extended with a lower-level per_op_gco2(energy_kwh, intensity, pue) helper that is the single source of truth for the energy × intensity × pue multiplication. All three paths (proxy, hourly, Scaphandre) go through this helper. The bulk helper is implemented as io_ops × per_op_gco2(ENERGY_PER_IO_OP_KWH, intensity, pue).

Scaphandre per-process energy integration

The proxy model uses a fixed ENERGY_PER_IO_OP_KWH constant (0.1 mWh per op). This is a two-order-of-magnitude approximation and it treats all services and all workload shapes identically. Perf Sentinel offers opt-in support for replacing the proxy with a measured service-level coefficient derived from Scaphandre's per-process power readings.

How it fits the architecture. Scaphandre is an external, user-installed process. Perf Sentinel does NOT bundle or fork Scaphandre. It scrapes the Prometheus /metrics endpoint Scaphandre already exposes. The score/scaphandre/ module owns:

  • ScaphandreConfig: parsed from [green.scaphandre] in .perf-sentinel.toml.
  • ScaphandreState: backed by ArcSwap<HashMap<String, ServiceEnergy>> for lock-free reads from the scoring path. The scraper builds a fresh Arc<HashMap> on each successful scrape and atomically swaps it in. Readers do a single load_full() to get their own Arc reference without contending on a lock.
  • spawn_scraper(): a tokio task that runs every scrape_interval_secs and updates the state.
  • parse_scaphandre_metrics(): escape-aware Prometheus text parser. Iterates by .chars() for UTF-8 safety. Has a fast path that avoids allocation when no backslash escapes are present in label values. Handles \" and \\ sequences inside label blocks.
  • OpsSnapshotDiff: a snapshot-diff helper that reads the per-service op counts from MetricsState::service_io_ops_total and computes the delta since the previous scrape.
  • apply_scrape(): applies the parsed power readings + op deltas to the state using the formula below.

The formula. For each mapped service in a scrape window:

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

When ops_observed_in_window == 0, the existing state entry is kept unchanged rather than cleared. This avoids model-tag flapping for idle services. The staleness threshold (3× the scrape interval) guards against stuck scrapers.

Where the coefficient plugs in. The daemon takes a synchronous snapshot of all energy sources at the start of each process_traces tick via build_tick_ctx. This merged map is attached to CarbonContext.energy_snapshot for the duration of the tick. Each EnergyEntry carries both the coefficient and a model tag ("scaphandre_rapl" or "cloud_specpower"). Inside compute_carbon_report's span loop, the per-op energy is resolved as:

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);

The scoring stage tracks per-region flags (any_alumet, any_scaphandre, any_kepler_ebpf, any_redfish_bmc, any_cloud_specpower, any_realtime) and the top-level CarbonEstimate.model reflects the most precise source used: "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". When calibration factors are active on proxy models, +cal is appended. All energy sources compose naturally with hourly profiles: a measured-energy op in eu-west-3 at 3am UTC uses the measured energy AND the hourly intensity simultaneously.

Per-service op counter as single source of truth. The scraper reads the per-service op counter from MetricsState::service_io_ops_total (a Prometheus CounterVec labeled with service and, since 0.19.0, grouping) via snapshot_service_io_ops(), which folds the grouping axis back to one total per service. The daemon's event intake path increments this counter on every normalized event. Using the Prometheus counter directly, instead of a parallel counter that would need resetting every scrape window, avoids reset races and gives Grafana users a per-service op rate graph for free.

Graceful shutdown. The daemon captures the scraper JoinHandle and calls .abort() on it before the final process_traces drain in the Ctrl-C arm. This prevents "scrape failed" log lines from appearing after the "Shutting down daemon" message.

What Scaphandre does NOT do. See the Scaphandre precision bounds section in Limitations for the full discussion. Scaphandre gives per-service coefficients, not per-finding attribution. Two N+1 findings in the same JVM during the same scrape window share the same coefficient by construction, because RAPL is process-level not span-level.

Cloud-native energy estimation (CPU% + SPECpower)

For cloud VMs (AWS, GCP, Azure) that do not expose Intel RAPL to guests, Perf Sentinel offers an alternative energy estimation path based on CPU utilization metrics and the SPECpower model. The module lives in score/cloud_energy/ and mirrors the Scaphandre module structure.

Architecture. The cloud_energy/ directory contains:

  • config.rs: CloudEnergyConfig and per-service ServiceCloudConfig (provider, instance_type or idle/max watts overrides, optional cpu_query). cpu_query_groups groups the services by their trimmed resolved CPU query.
  • table.rs: embedded lookup table with idle and max watt values for every type listed in Instance types, after the 2026-04-24 CCF refresh. All entries follow a single homogeneous methodology: idle_watts = vCPU * idle_per_vCPU and max_watts = vCPU * max_per_vCPU, with coefficients sourced per provider from ccf-coefficients 2026-04-24 (coefficients-{aws,gcp,azure}-use.csv). No baseboard overhead is reconstructed: the AWS-specific baseboard column was dropped by CCF in 2026-04-24 and is not added back. The 5 percent rule splits modern entries into two groups. Entries are re-aligned to CCF when the SPECpower direct compute diverged (Sapphire Rapids on AWS m7i/c7i/r7i and GCP c3, EPYC Genoa on AWS m7a/c7a and GCP c3d/n2d, Graviton 3 m7g/c7g and Graviton 4 m8g/c8g mapped to the CCF EPYC 2nd Gen proxy, EPYC Turin on AWS m8a/c8a, Emerald Rapids on GCP c4). They stay on SPECpower_ssj 2008 direct compute 2024 Q1 - 2026 Q2 when within 5 percent or absent from the provider CSV (AWS Milan m6a/c6a, GCP Turin c4d, GCP Ampere Altra t2a, Azure Sapphire Rapids, Azure Emerald Rapids, Azure Genoa, Azure Cobalt 100, Sierra Forest). New AWS families added by this refresh: m8a / c8a (Turin), m8i / c8i (Emerald Rapids), r7a (Genoa memory-optimized). New GCP family: c4a (Axion ARM Neoverse V2, proxied to AWS Graviton 4). Later rows add Graviton 2 (m6g/c6g/r6g/t4g) and memory-optimized Graviton (r7g/r8g), and GCP n1 and Azure Standard_B*, which run on several processor generations, take the one with the highest max W/vCPU the CCF CSV publishes (see Limitations). resolve_instance_class maps managed database, cache, broker and search class names and AWS or Azure modifier names to the row of their base family, or to an Unsupported reason, and lookup_instance_power keeps the provider default for anything else. See Limitations.
  • scraper.rs: Prometheus JSON API scraper. Sends each distinct resolved CPU query as written, with no templating, once per tick, and reads the value of the first series and the series count.
  • state.rs: CloudEnergyState backed by ArcSwap for lock-free reads from the scoring path.
  • mod.rs: re-exports and module documentation.

The formula. For each distinct CPU query whose response holds one series, read by services that resolve one (idle_watts, max_watts) envelope:

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 of the services reading the query)

Every service of the group with ops in the window gets that energy_per_op_kwh, so the node is counted once. A response with several series, or a group whose services resolve different envelopes, keeps the per-service computation: each service applies its own envelope to the first series and divides by its own ops. The multi-series case warns once per query, the mixed envelopes warn at config load. idle_watts and max_watts come from the SPECpower table lookup by instance type or from user-provided overrides in the config. The op count comes from the same MetricsState::service_io_ops_total counter used by Scaphandre.

Config example.

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, modern entry
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), modern entry
cpu_query = '100 - (avg(rate(node_cpu_seconds_total{mode="idle",instance="api-eu:9100"}[5m])) * 100)'

Model tag and precedence. The coefficient carries model tag "cloud_specpower". In build_tick_ctx, higher-fidelity sources take precedence: Alumet overrides Scaphandre overrides Kepler overrides Redfish overrides cloud SPECpower for the same service. The top-level model tag reflects the most precise source: 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 only. Like Scaphandre, cloud energy estimation is a daemon-only feature. The analyze batch command always uses the proxy model.

What cloud SPECpower does NOT do. See Limitations "Cloud SPECpower precision bounds" for the full discussion. The SPECpower model captures CPU-proportional power but not memory, I/O or network power. Services on one node share its watts only when they read one single-series query, and untraced tenants are attributed to the traced services. Accuracy is approximately +/-30%.

Alumet interval-energy attribution

The [green.alumet] integration scrapes Alumet's prometheus-exporter output plugin and follows the same shared-state pattern as every other measured source (ArcSwap-backed AgedEnergyMap, 3 × scrape_interval staleness gate, per-service OpsSnapshotDiff). Two things make it structurally different.

A third reading shape. Scaphandre exports an instantaneous microwatt gauge, Kepler a monotonic cumulative joules counter, Alumet neither. Its exporter publishes every measurement as a Prometheus gauge holding the last flushed value, and rapl_consumed_energy is declared CounterDiff: the joules consumed during one source poll_interval (upstream default 1s, flushed every 5s). The value is therefore an interval delta that is re-published unchanged between flushes. Neither summing across scrapes (double-counts within a flush window, drops intervals across one) nor delta-ing against the previous scrape (the value is already a delta) is correct. The scraper divides by the operator-declared energy_interval_secs to recover mean watts, then integrates over its own scrape window:

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

Past the division this is Scaphandre's formula verbatim, and it inherits the same stationarity assumption. It also means alumet/apply.rs holds no cross-tick state: each scrape stands alone, and an exporter restart needs no counter-reset guard.

energy_interval_secs is unverifiable. The interval is nowhere in the exposition, and no runtime signal distinguishes a correct declaration from a wrong one. A drift from the Alumet-side poll_interval rescales energy and carbon linearly and silently, while still tagging the result measured. Auto-detection was considered (watch the gauge and time its changes to infer the flush period) and rejected: it infers flush_interval, not poll_interval, and a clever-but-fragile heuristic is worse than a documented config coupling for a value the operator already has in front of them. The mitigation is threefold: the field is echoed in the Alumet scraper started log line, Configuration shows both files side by side, and Limitations names it as the largest correctness risk for a RAPL series. Upstream exposing either a cumulative counter or the interval itself would retire the field.

Joules are assumed, names are checked. The scraper reads every value as joules. At v0.9.5 the exporter publishes no unit for a metric that has one (no name suffix, no # UNIT line), and energy-attribution declares joules whatever its formula computes, so the wire cannot settle the unit. A non-RAPL source would land in the wrong unit (GPU and Grace energy in millijoules, Jetson power in milliwatts) and under the alumet_rapl tag, so the integration is restricted to RAPL-derived series. is_non_rapl_series in alumet/scraper.rs backs that rule with a warning at scraper start, on the known non-RAPL source names and on a non-joules unit suffix, the shape Alumet's development branch gives names after v0.9.5. A unit knob was not added: it would fix the magnitude and still publish GPU energy as alumet_rapl.

Operator-supplied parser inputs. Kepler's metric name and label key are pinned by the KeplerMetricKind enum because upstream fixes both. Its zone filter is left to the operator because the zone names depend on the meter Kepler reads (RAPL domains or hwmon sensors). Alumet fixes neither: the exporter applies a configurable prefix/suffix (default suffix _alumet) to every name, and the per-service series comes from an energy-attribution formula the operator names. So metric_name and label_key are mandatory config with no default, validated for control chars and length in validate_alumet, and rejected loudly at load time when absent (validate_alumet_raw, called from load_from_str before the lossy Config::from). Neither string ever reaches a Prometheus label. They are matched against the scraped body, so there is no cardinality exposure. This generic (metric, label) surface is also what lets one config shape cover every RAPL-derived Alumet series: k8s pods via name, raw RAPL domains via domain, procfs PIDs via resource_consumer_id.

Precedence. alumet_rapl leads the measured chain, above scaphandre_rapl. Both read RAPL, but Alumet's sampling is measurably less error-prone (Raffin et al., <https://hal.science/hal-04420527v2/document>) and it attributes per cgroup rather than per process. As with every rank in this chain, it orders attribution fidelity, not wall-plug coverage (see the Redfish note below).

Shared parser. parse_metric_samples in score/prom_parser.rs was extracted from kepler/parser.rs when Alumet became its second consumer: the code was already generic over (metric_name, label_key). parse_metric_samples_where adds an optional (label, value) filter, used by Kepler only. scaphandre/parser.rs stays separate because it extracts two labels (exe, cmdline) in a single pass, so its scan loop legitimately diverges.

Broker energy attribution

A broker poses the database's problem twice over: it burns the energy of an N+1 publish loop, it emits no span of its own, and it is very often managed, so there is no host to run an agent on. Perf Sentinel reuses the database_waste shape with the messaging-only ratio, broker energy × (avoidable publish ops / total publish ops), reported as green_summary.messaging_waste. A measured or declared figure is broker host energy, which enters the totals as the G_msg host term once its carbon converts (see "Database and broker host terms" above). With neither source, the figure is the estimated form: the application-side share of the publish spans, the energy span scoring gave the publishing services, already inside the totals and holding no broker host energy, so it never enters them a second time.

Why not a per-publish coefficient. Covered below under "Why no per-publish coefficient can be a measurement". Broker power stops tracking throughput past roughly 20 % of capacity, so marginal energy is not a constant, and the three determinants (utilisation point, replication factor, topology) are invisible from a producer span. The figure is a workload-level measurement split by a count-based ratio, never a coefficient.

Why cloud_energy could not be reused. The obvious idea is to feed the broker through the existing SPECpower CPU% path. The per-op path blocks it three times over: ops_snapshot_diff produces no op delta for a workload that emits no spans, cloud_energy/table.rs guards on ops == 0, and the region gate in carbon_compute.rs is never reached. Only the Alumet-database pattern, which bypasses the span loop entirely, applies. lookup_instance_power is reused directly, but the surrounding machinery is not.

Two sources, one accumulator. [green.alumet.broker] measures the broker cgroup, a second DbEnergyState instance beside the database one. [green.broker_static] declares a provisioned cluster and needs no agent at all, which suits a provisioned cluster with a typed instance class (a managed Kafka or MQ cluster, self-managed VMs). Serverless and capacity-unit brokers have no provisioned node and cannot be declared. Its model is E(n) = n × P_max, resolved once at config load rather than per batch.

What that declared figure bounds is narrower than it reads. SPECpower covers CPU and baseboard, so it ceilings the declared vCPUs' compute and not the cluster's wall power, and a storage-bound broker can draw more. It also does not move when the application starts batching, which is a weakness for a figure meant to show remediation working. MAX_BILLABLE_MS truncates an idle stretch to one hour, so the declared figure under-counts there. All three are stated in Limitations.

Arbitration is the subtle part. A measurement outranks a declaration, but "the measurement is live" is not the tick-level question it looks like.

  • Per interval, not per tick. Alumet delivers retroactively: one delta covers every interval since the previous one, so a live series owns the gaps between its deltas too. Arbitrating on "did a delta land this tick" bills those gaps twice, once per model tag.
  • The series, not the endpoint. mark_alive fires on every successful scrape whether or not the workload's label appeared, which is right for keeping banked energy alive across an idle workload. Ownership needs the stronger signal has_recent_sample, or a mistyped label_value leaves the endpoint answering forever and suppresses the declaration permanently, publishing nothing at all.
  • Banked energy is real. When the series goes quiet, whatever it banked while live is delivered before the declaration takes the window. A label that was never seen has nothing banked, which lets a typo fall through to the fallback.
  • The declaration owns the stretch it billed. From the tick a fallback bills until the series recovers, any series delta covering that wall clock is dropped via discard_pending, gated on an outage marker carried by StaticBrokerState. The marker makes this safe to apply on a stale tick as well as on the recovery tick: it is only set once a declaration has billed, so the first stale tick still delivers whatever the series banked while it was live. Discarding on every fallback tick regardless of the marker would wipe those joules.

The figure carries its provenance. DbEnergyContext.model is filled by whichever source populated the window, because nothing downstream can infer it. Its Default is the weakest of the four tags (estimated), so a caller that forgets to state provenance cannot claim a measurement. The periodic disclosure keeps four buckets rather than two: folding a declared cluster into measured_energy_kwh would publish an operator's statement about provisioned hardware as a reading of the broker. A CPU-interpolated cloud_specpower figure stays out of measured_energy_kwh too, in modeled_energy_kwh.

Database energy attribution

The database figure has the broker's shape with the SQL-only ratio, and the same two sources. [green.alumet.database] measures the database cgroup. [green.database_static] with placement = "dedicated", nodes and instance_type declares a provisioned cluster, billed at E(n) = n × P_max by the same StaticBrokerState ceiling under the database_specpower tag. That suits a managed database with a typed instance class, whose managed name resolves to its VM family. The ceiling's bounds on a database (a lightly loaded host, readers and standbys counted by the operator, managed storage, IOPS and network left out) are in Limitations.

Host groups. A declaration of hosts of different sizes lists [[...hosts]] groups. StaticHostsConfig::groups turns them into single-group declarations, and the daemon builds one CPU probe and one ceiling per group, paired in SubsystemSources, so the live-versus-declared arbitration above runs per group without a change. take_host_energy sums the groups' live and declared parts. A window where any group billed its declaration takes the declared tag for the whole figure, with both parts summed, so a fallback is never published as a reading. The cloud scraper fetches every probe at once, and each subsystem's age gauge reads its oldest probe. Groups are refused next to the Alumet workload, which keeps the Alumet state the single group's live source.

The arbitration is reused, not copied. take_broker_energy arbitrates both subsystems unchanged: a fresh live series owns the timeline and advances the ceiling marker without billing, a stale one lets the ceiling bill and set the outage marker, and the delta that lands on recovery is discarded once. take_host_energy calls it for one subsystem. Every property listed under "Arbitration is the subtle part" above holds for the database as for the broker.

One bundle per subsystem. EnergySources carries database and broker, each a SubsystemSources { live, live_staleness_ms, declared }. The live state is the Alumet workload with the Alumet staleness, or the DbEnergyState the cloud scraper bills from the section's cpu_query with the cloud staleness (3 × scrape_interval). Load-time validation refuses a cpu_query next to the Alumet workload, so a subsystem has one live state at most. Each live state travels with its own staleness, so one subsystem's reading is never gated by the other's freshness window. With green scoring off, host_sources hands the loop two empty bundles, so nothing consumes banked energy that score_green would never read. The live cell keeps a host figure for twice the Alumet staleness or twice the cloud staleness when a CPU probe runs, whichever is longer, raised to trace_ttl_ms when either subsystem declares a ceiling, which has no scraper to bridge sub-second batches.

Base tags. host_energy_context builds a context only for a subsystem with a source the daemon bills, and its model is the base tag: alumet_rapl with an Alumet workload, otherwise cloud_specpower for a section with a cpu_query, the live source that query names (every shared section has one, D5/B5), and the declared tag for a ceiling without one. patch_host_energy keeps the base tag when a live reading fills the window and writes the declared tag (database_specpower, broker_specpower) when the ceiling does, so a figure names the source of its kWh. A shared section has no ceiling, so its windows always carry cloud_specpower and a stale probe bills nothing. A ceiling-less declaration without a cpu_query builds no context, and the estimated figure stays.

The CPU probe. setup_cloud_scraper builds one HostProbe per section with a cpu_query, database first. A dedicated one (HostProbe::dedicated) has the section's nodes and the node_power() envelope of its instance type, a shared one is described below, and spawn_cloud_scraper runs them on the cloud ticks after the service queries, whether or not a service reading succeeded. fetch_host_cpu runs one fetch_cpu_percent: a response with several series fails with MultipleSeries, and a failure or a non-finite value bills nothing, warns once until the next good reading and advances the subsystem's *_cpu_last_scrape_age_seconds gauge. bill_host_probes adds nodes × interpolate_watts(idle, max, cpu) × elapsed / 3_600_000 kWh to the probe's state, elapsed being the seconds since the previous tick (the interval, longer when a slow tick delays the next one, the same span the service ops delta covers), with add_window_kwh, which also marks the sample, so take_broker_energy arbitrates the probe exactly like an Alumet series: after three intervals without a reading the ceiling bills the outage under the declared tag, and the first delta after recovery is dropped.

The shared subtraction. A shared probe (HostProbe::shared) is one node. Its envelope is the one of the group shared_with lists, which D7/B7 hold to one envelope, else the node_power() of its instance type, and its group_key is that group's trimmed query, the key of the CloudGroup reading the node. It bills its dynamic share only, (max - idle) × clamp(cpu)/100, the node's idle being already paid by its services. With an empty shared_with that share is billed and nothing is deducted. With a listed group, it needs a reading of group_key holding one series, else it bills nothing that tick, and it is capped by a joint budget per group: budget_g = (max - idle) × clamp(c_g)/100 minus what earlier probes took on that group this tick, in probe order, database first, so a database and a broker on one node never bill more than its dynamic watts together. bill_host_probes runs before apply_cloud_scrape and returns the watts per group query. For a split group, apply_cloud_scrape publishes P' = max(interpolate_watts(idle, max, c_g) - deductions[g], idle) and splits it by op count. The per-service fallback (several series, mixed envelopes) never receives a deduction, and a listed group without a one-series reading bills its host nothing, so a host is never billed without being taken out of its node.

Conservation. The cap gives P' >= idle by construction, the max is a guard. On a tick where the group's services emitted ops, Σ_services e × ops × 3.6e6 / interval + Σ_probes on g w = interpolate_watts(idle, max, c_g): the node is counted once, the hosts' share in the host terms and the rest in the services' coefficients. On a tick without ops, w is still billed and that tick's application remainder is unattributed, the services keeping their previous rows until they age out.

The region never switches. A context's region is the subsystem's effective region, GreenConfig::host_region: the static section's, else the Alumet workload's, else default_region. Load-time validation refuses a static and an Alumet region that differ, so the live and declared sources of one subsystem share it, and patch_host_energy never touches it. One cluster sits on one grid whichever source billed the window, so its carbon intensity does not change with the source.

Kepler and Redfish attribution notes

The Kepler and Redfish integrations follow the same shared-state pattern as Scaphandre and cloud SPECpower (ArcSwap-backed AgedEnergyMap, 3 × scrape_interval staleness gate, per-service OpsSnapshotDiff) but each carries its own methodology trade-offs that warrant a dedicated note.

One source, one count, on every backend. Each backend hands a source to energy_state::split_by_ops with the services that read it. The ops of the services active this tick are summed, the source's energy is divided by that sum, and each active service gets the resulting energy_per_op, so energy_per_op × ops adds back up to the reading. The source is a Kepler or Alumet label value (grouped by energy_state::services_by_key over service_mappings), the one Scaphandre process a process_map matcher selects, a Redfish chassis, or a cloud CPU query returning a single series. A service with no ops this tick keeps its previous row. A source read by one service gives the same coefficient as before the split.

Kepler counter-delta semantics. Kepler exports a monotonic cumulative joule counter per container/process, unlike Scaphandre's instantaneous microwatt gauge. The scraper task holds a HashMap<label value, last_raw_joules>, one counter per label however many services map to it, and on every tick computes delta = current - previous, where current is the SUM of every counter sharing the mapped label value within one zone. Kepler emits one series per zone and the zones overlap (package contains core), so parse_metric_samples_where drops the rows whose zone label differs from [green.kepler] zone before the sum. Rows without a zone label pass through. A container name repeated across pods yields several series, and a last-write-wins read would flip between counters with exposition order. The entry is emitted only when delta > 0.0 && delta.is_finite(). The filter covers Kepler exporter restarts: the counter resets to zero, current < previous produces a negative delta, and the guard drops it. Non-finite readings (NaN, ±Inf) are also dropped. The next scrape produces the next meaningful delta from the fresh baseline. The first observation per label value (no previous) emits no delta. The raw counter is recorded for the next scrape.

Kepler scrape mode (direct vs Prometheus-mediated). Kepler typically runs as a Kubernetes DaemonSet (one pod per node). The current [green.kepler] integration performs a direct GET and therefore needs an endpoint that exposes Kepler series themselves, either one node-local exporter per Perf Sentinel or a federation/proxy endpoint exposing aggregated series. A Prometheus server's own /metrics endpoint is not such an endpoint. A future source = "prometheus" mode will issue PromQL queries against an upstream Prometheus. The metric_kind enum already distinguishes the Kepler series to read.

Redfish node-level attribution formula. Redfish exposes one wattage reading per chassis, not per service. The scraper turns that into a per-service energy-per-op coefficient 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    (in kWh per op)

Every service mapped to the chassis receives the same energy_per_op for that scrape window. This is the correct interpretation of node-level power until finer signal is available, and it is documented as a known coarseness in Limitations "Redfish BMC precision bounds". Idle chassis (no ops mapped this window) leave every service's previous entry untouched, with no division by zero and no flapping. Non-finite, null, zero, or negative wattage readings are rejected as BMC transitional states, and the previous coefficient is preserved.

Redfish TLS limitation. Most BMCs ship with self-signed certificates by default. Perf Sentinel's shared http_client::build_client uses hyper-rustls with the Mozilla roots plus the PEM certificates of SSL_CERT_FILE, so a self-signed cert is rejected unless it sits in that bundle. The RedfishConfig::ca_bundle_path field anticipates a per-endpoint CA bundle, but its PEM loading is deferred to a later release. Setting ca_bundle_path today causes the scraper to log an ERROR and refuse to start. This fail-loud behavior lets operators with self-signed BMCs see the gap immediately rather than discover it during a TLS handshake far from the config they set. A BMC certificate issued by a private CA, or a self-signed one, is trusted once it is in SSL_CERT_FILE, provided its subject alternative names cover the endpoint host. A self-signed one must also not be a CA certificate: rustls-webpki rejects a CA certificate presented as the server certificate (CaUsedAsEndEntity). Otherwise the workarounds in the current release are a reverse proxy that presents a trusted cert, or HTTP on a trusted network segment.

Redfish vendor JSON variance. BMC vendors serve slightly different shapes under /redfish/v1/Chassis/{id}/Power, and some no longer serve it. The legacy_power schema reads PowerConsumedWatts of the PowerControl entry whose PhysicalContext is Chassis, else of entry 0, since DMTF does not require entry 0 to cover the whole chassis. The environment_metrics pointer /PowerWatts/Reading and the sensor pointer /Reading are fixed per schema, and a sensor body is read only when its ReadingUnits is W and its PhysicalContext is Chassis or absent. v0.7.6 removed the power_path field, so vendor OEM properties are not configurable. The parser rejects null, 0, negative, and NaN as invalid so transitional BMC states do not poison the coefficient.

Redfish rate-limit defense. scrape_interval_secs is clamped to [15, 3600] for Redfish (compared to [1, 3600] for Scaphandre and Kepler). A BMC is a small management controller that other tools query too, and polling it faster than its readings refresh adds load without adding information. A throttled request (HTTP 429) counts as a failed scrape. Default is 60 s.

Accepted SSRF surface. The Kepler, Redfish, Scaphandre and cloud-energy scrapers all fetch from a loopback or RFC 1918 URL (http://127.0.0.1:9102/metrics, https://10.0.0.5/redfish/v1/...). These targets are expected: Kepler typically runs as a DaemonSet on the same node, BMCs live on management networks, Scaphandre exposes a local Prometheus endpoint. Config-load validation refuses URLs with embedded credentials (@) or control characters, and the body cap inside http_client::fetch_get (8 MiB) bounds the per-fetch memory. The shared hyper-util legacy client is built without redirect-following so a malicious endpoint cannot 302 the scraper to http://169.254.169.254/. The deployment-time guarantee is that every URL the daemon reaches is operator-supplied via .perf-sentinel.toml, never derived from external input (spans, BMC responses, Prometheus query results).

Two-axis carbon tagging. Energy fidelity (E, ranked by [carbon_compute::higher_fidelity_measured]) and grid-intensity fidelity (I, surfaced by [region_breakdown::select_co2_model_tag]) are independent axes. A single window can carry co2.model = "electricity_maps_api" (real-time intensity is the highest-fidelity I source) while per_service_energy_model reports "scaphandre_rapl" for the same service (RAPL is the highest-fidelity E source). Tagging the report by the most precise I source while the per-service breakdown tracks E lets auditors see both dimensions without folding them into a single tag.

Naming the energy source on display. Four display surfaces name the E source from per-service coverage: three terminal surfaces through GreenSummary::energy_source_label, and the dashboard's Energy card with the same rules in JavaScript. A service counts as covered once its per_service_measured_ratio is above zero. The label reads source <tags> when every service is fully covered, source <tags> on N of M services · rest modeled from I/O counts when they mix, and modeled from I/O counts when none is. The modeled part is followed by · calibrated when GreenSummary.energy_calibrated is set (the ReportFlags calibration flag, which select_co2_model_tag drops behind a measured or real-time tag) or a tag carries +cal, for reports written before the field. The window energy_model is never displayed as the source: it follows select_co2_model_tag, so it reads electricity_maps_api on a daemon with Electricity Maps, and io_proxy_vN names the intensity tier. Tags outside [A-Za-z0-9_+-]{1,64} are dropped before display, which keeps a hostile --input report out of the terminal and the page. They are the GreenOps tab of the HTML dashboard (an Energy card after Total CO2), the analyze terminal summary, the inspect analyze panel and the query monitor Energy tab, whose By-service source column reads I/O counts for an uncovered service.

Electricity Maps real-time intensity integration

The [green.electricity_maps] block enables real-time grid carbon intensity polling. The daemon scraper periodically queries the Electricity Maps /carbon-intensity/latest endpoint per zone and feeds the result into the per-tick CarbonContext, where it overrides annual and hourly profiles for matched cloud regions. Documented at <https://app.electricitymaps.com/developer-hub/api/getting-started>.

Per-zone deduplication. The scraper iterates over region_map (cloud_region -> zone) but a single zone is fetched at most once per tick, even when several cloud_region keys point to the same zone (typical multi-AZ setups, or aws:eu-west-3 and local-k3d both pinned to FR). The reading is then dispatched to every matching cloud_region. This keeps the API call count proportional to the number of distinct zones, not to the size of region_map. This matters on quota-constrained tiers. Commercial plans are priced per zone, and the same zone mapped from staging and prod still costs one call per tick.

Estimation metadata. The Electricity Maps API surfaces two optional fields alongside carbonIntensity:

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

isEstimated is true when the API filled a gap (Tier B/C zone, or temporal hole bridged by an algorithm such as TIME_SLICER_AVERAGE), and false for fully measured values. Perf Sentinel parses both fields with #[serde(default)] to stay forward-compatible if a future API version stops emitting them.

The flags propagate through IntensityReading (state) into the per-tick CarbonContext.real_time_intensity and finally into the per-region accumulator. The green_summary.regions[] row exposes them as two optional fields:

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
}

Both fields use #[serde(skip_serializing_if = "Option::is_none")] so consumers that ignore them continue to deserialize the row unchanged. The fields only appear when intensity_source == "real_time". Spans falling back to annual or hourly profiles never carry the metadata, even if the accumulator captured it from a sibling span.

This is the signal Scope 2 reports need to distinguish measured emissions from modeled ones. Auditors typically allow estimated values when the methodology is documented. Surfacing the algorithm tag (TIME_SLICER_AVERAGE, GENERAL_PURPOSE_ZONE_DEVELOPMENT, etc.) makes the audit trail self-contained.

User-facing rendering (0.5.10)

The two fields are surfaced in the two user-visible rendering layers so operators read the distinction at a glance.

Dashboard. The Regions table in the GreenOps tab carries a sixth column Estimated. Three visual states: an orange Estimated badge when intensity_estimated == true (hover surfaces a tooltip with the intensity_estimation_method), a green Measured badge when intensity_estimated == false, and a neutral dash for rows whose intensity_source is not real_time. Annual / hourly / monthly_hourly profiles carry no estimation metadata, so the field stays None end-to-end. Both badges reuse the existing palette CSS variables (--color-background-warning, --color-text-warning, --color-background-success, --color-text-success) so dark and light themes adapt automatically.

Terminal. The print_green_summary per-region line gains a suffix after the source: real_time field. 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)

The suffix is empty when intensity_estimated is None, so existing log scrapers keep matching pre-0.5.10 line shapes.

API version (0.5.11)

Perf Sentinel targets the Electricity Maps API v4 endpoint by default since 0.5.11. Earlier releases defaulted to v3, which Electricity Maps still serves but considers legacy. The migration was triggered by the v4 promotion to "latest" in the developer hub reference (<https://app.electricitymaps.com/developer-hub/api/reference>) and is forward-defense against an eventual v3 retirement.

The response schema on the carbon-intensity/latest endpoint is byte-identical between v3 and v4, so the migration is transparent for downstream consumers. green_summary.regions[] rows are unchanged regardless of the configured API version, and the parsing path is the same struct.

Backward compatibility: existing .perf-sentinel.toml configs that pin endpoint = "https://api.electricitymaps.com/v3" keep working. The scraper detects the legacy path at startup via ApiVersion::from_endpoint, which matches .../v3 at end of URL or .../v3/... in path, with word-boundary guards against false positives like /v30 or /v300. It then emits a tracing::warn! message once per daemon start, pointing the operator to the v4 migration. Since 0.5.12 ApiVersion::from_endpoint is the single source of truth and is also consumed by the green_summary.scoring_config.api_version field. The endpoint string flows through sanitize_for_terminal before being logged so a hostile TOML cannot inject ANSI control bytes into the daemon log stream.

Scoring config transparency (0.5.12)

The green_summary.scoring_config object exposes the runtime configuration of the Electricity Maps integration so auditors and Scope 2 reporters can see which carbon model produced the numbers without reading the operator's TOML. Three fields, each derived from ElectricityMapsConfig at config load time via ScoringConfig::from_electricity_maps:

  • api_version: detected from api_endpoint via ApiVersion::from_endpoint. One of v3 (legacy), v4 (default), custom (proxy or mock without /vN suffix).
  • emission_factor_type: mirrors the TOML knob, one of lifecycle (default) or direct.
  • temporal_granularity: mirrors the TOML knob, one of hourly (default), 5_minutes, 15_minutes.

Scope of the surface. scoring_config started (0.5.12) as the Electricity Maps client configuration only, a partial methodology footprint rather than the full SCI input vector. 0.9.25 added the coefficients that scale the figures: embodied_per_request_gco2, network_energy_per_byte_kwh (the fixed value since the key was deprecated), per_operation_coefficients and use_hourly_profiles. It also added an electricity_maps flag, because the object now ships on every green-scored run and its presence alone no longer means the API is configured. What a strict replay still lacks is the per-region PUE drawn from the embedded provider table, recoverable only if the Provider classification is stable across runs. The disclosure republishes the same coefficients under methodology.calibration_inputs.scoring_coefficients, folded from the archived windows rather than read from the machine running disclose.

Backward compat. The field is None (and the dashboard bandeau / terminal line are hidden) when [green.electricity_maps] is not configured, so reports produced without Electricity Maps stay shape-identical to pre-0.5.12. The wire form is additive on the JSON green_summary via #[serde(skip_serializing_if = "Option::is_none", default)], so pre-0.5.12 baselines fed back through report --before keep parsing.

Threading. Config::carbon_context() populates CarbonContext::scoring_config: Option<ScoringConfig> from the loaded green_electricity_maps. score_green reads it from the context and copies it into the resulting GreenSummary. The daemon's per-tick build_tick_ctx inherits the field via the existing Cow::Owned(ctx) clone path, no per-tick rebuild. The CLI batch pipeline gets it directly from the once-built CarbonContext.

Daemon snapshot path. Since 0.5.13, /api/export/report serves a live green_summary refreshed by the event loop after each batch (regions, top offenders, avoidable I/O ratio, CO2 numbers). scoring_config is stitched on top from the daemon's startup Config, so the audit chip and the GreenOps tab both surface on the rendered HTML when an operator pipes the snapshot through perf-sentinel report --input -. The earlier 0.5.12 limitation (snapshot returned GreenSummary::disabled(0) and only the scoring_config field was patched, hiding the GreenOps tab) is removed.

Defense against terminal injection: the three fields are typed Rust enums with bounded variants, so the terminal renderer in print_green_summary does not need to wrap them in sanitize_for_terminal (unlike intensity_estimation_method which carries a free-form String from --input JSON). The HTML chip rendering uses textContent (not innerHTML) and setAttribute("title", ...), both of which auto-escape.

Per-operation energy coefficients

The proxy model uses a single ENERGY_PER_IO_OP_KWH constant (0.1 mWh) for every I/O operation the emitting service issues. This treats a read-only SELECT hitting an index the same as a disk-heavy INSERT writing to WAL and data pages. The per-operation coefficient feature refines this by applying a multiplier based on the operation type. The multipliers are relative weights on the emitting service's own cost: they rank operation classes and bill no database host.

SQL verb multipliers. The verb is extracted from the first word of the target field (the raw SQL statement), not from the operation field. This is necessary because OTLP-ingested spans store db.system (e.g., "postgresql") in operation, not the SQL verb. The first whitespace-delimited token reliably gives the SQL verb across all ingestion formats (native JSON, OTLP, Jaeger, Zipkin).

SQL verbMultiplierRationale
SELECT0.5xRead-only index lookup, no WAL write
INSERT1.5xWAL write + data page write
UPDATE1.5xRead + write
DELETE1.2xMark + WAL
Other1.0xDDL, EXPLAIN, BEGIN, etc.

HTTP payload size tiers. For HTTP spans, the multiplier depends on response_size_bytes (extracted from OTel http.response.body.size or legacy http.response_content_length).

Payload sizeMultiplierThreshold
Small0.8x< 10 KB
Medium1.2x10 KB to 1 MB
Large2.0x> 1 MB
Unknown1.0xattribute absent

Messaging spans are unweighted (1.0x). A publish is not given the HTTP tiers, even though messaging.message.body.size is ingested into the same response_size_bytes field. The tier boundaries encode a web payload distribution: 1 MB is Kafka's default max.message.bytes, a protocol ceiling rather than a large message, so under a default broker configuration nearly every message would land in the 0.8x tier. The result would be a near-constant 20% discount rather than a size-sensitive model. None of the three sources below measured a broker, so borrowing their ratios would extend their authority to a domain they never covered. The size attribute is ingested so a measured coefficient can be calibrated later through calibrate against an Alumet run on a broker bench, the same path that produced the SQL ratios.

Why no per-publish coefficient can be a measurement. The stronger reason is not the absence of a published figure but that a broker's power does not track its message rate. An ACM/SPEC ICPE 2026 study measured Kafka and Redpanda with Scaphandre over RAPL (3 brokers, replication factor 3, 4 KiB messages, 60 partitions per broker, on NVMe, SSD and HDD clusters). It reports that "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", attributing it to fixed thread pools, against roughly 50 % for Redpanda. The marginal energy of one publish therefore falls towards zero past that knee, and any per-operation coefficient is an amortized allocation of mostly fixed power rather than a measurement of marginal work. An older wattmeter study points the same way at the storage layer: on the Filebench Web workload, XFS sustains far more operations than Ext2 for only 29 % more power, roughly 6.75x less energy per operation for identical application work (Sehgal, Tarasov, Zadok, "Evaluating Performance and Energy in File System Server Workloads", USENIX FAST 2010). Energy per operation is dominated by fixed power and by the software stack, not by the content of the operation. This bounds what the whole proxy model can claim, SQL and HTTP coefficients included, and it is why the messaging coefficient asserts nothing.

Two Kafka defaults also break the naive "one publish equals one durable write" reading, in opposite directions. flush.messages defaults to Long.MAX_VALUE and the project itself recommends against setting it ("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"), so billing one fsync per publish is wrong. Conversely, the typical durable setup (replication factor 3, min.insync.replicas=2, acks=all) yields about one append per replica, and that fan-out is driven by replication.factor, not by acks.

Standards footing. The Green Software Foundation SCI specification (text v1.1.0, not the paid ISO/IEC 21031:2024 publication, which was not consulted) permits a modelled value: "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." That is a conditional fallback tied to the data being inaccessible, not a free choice, and no GSF sub-specification covers broker or messaging workloads.

Sources. The relative ratios are derived from academic DBMS energy benchmarks (Z. Xu, Y.-C. Tu, X. Wang, "Exploring Power-Performance Tradeoffs in Database Systems", IEEE ICDE 2010, pp. 485-496; Tsirogiannis, Harizopoulos, Shah, "Analyzing the Energy Efficiency of a Database Server", SIGMOD 2010, pp. 231-242; Lella et al., "DBJoules: An Energy Measurement Tool for Database Management Systems", arXiv:2311.08961, 2023) and the Cloud Carbon Footprint methodology, borrowed as relative weights only: those benchmarks measured database servers, while the weights scale the emitting service's own per-op cost and bill no database host. The absolute values are order-of-magnitude estimates. The relative ordering (SELECT < DELETE < INSERT/UPDATE) holds better across hardware generations.

Where it plugs in. In compute_carbon_report's span loop, the proxy fallback path applies the 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
};

When measured energy is available (any measured backend), the coefficient is NOT applied: the measured backend replaces the proxy on the same perimeter, the emitting service's host.

Hot path detail. The energy_coefficient() function is #[inline] and avoids allocation: it uses split_ascii_whitespace().next() (lazy, stops at the first space) for verb extraction and eq_ignore_ascii_case for matching instead of lowercasing. The most common verb (SELECT) matches on the first comparison.

Config toggle. [green] per_operation_coefficients = true (default). Set to false to use the flat constant. The model tag stays io_proxy_v1 or io_proxy_v2 regardless of this toggle. The per-operation coefficients are a refinement of the proxy model, not a new model class.

Network transport energy

For cross-region HTTP calls, the energy cost of moving bytes over the internet backbone can be significant. Perf Sentinel offers an optional network transport energy term.

The formula.

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

The default coefficient is 4e-11 kWh/byte (0.04 kWh/GB), a conservative default below recent whole-network averages. The Sustainable Web Design Model v4 (2024) uses 0.059 kWh/GB operational (plus 0.013 embodied) for networks, a figure that includes access networks which cross-region server traffic does not traverse. Inter-datacenter coefficients run as low as 0.001 kWh/GB (the Cloud Carbon Footprint networking coefficient, applied to gigabytes in their estimator). Mytton, Lunden and Malmodin (2024, "Network energy use not directly proportional to data volume", Journal of Industrial Ecology 28(4):966-980, doi:10.1111/jiec.13512) demonstrate that the kWh/GB model is only valid for retrospective allocation: network equipment has significant fixed baseload power, so energy does not scale linearly with data volume (their Table 2 shows the Shift Project 2019 fixed-network model at 0.31-0.53 kWh/GB against Telefonica's actual 0.07 in 2020). The default is therefore an upper bound for cross-region server traffic. Since 0.9.25 the coefficient is fixed rather than configurable, so every disclosure scales transport identically, and the disclosure publishes the term under the sourced bracket too: 0.001 kWh/GB low (Cloud Carbon Footprint) and 0.059 kWh/GB high (SWDM v4 network segment), as transport_kgco2eq_low / transport_kgco2eq_high.

The carbon intensity and PUE of the source region (where the data originates) are used, since the network infrastructure serving the request is co-located with the source.

Cross-region detection. Transport energy is only computed when caller and callee are in different regions. The mechanism:

  1. Caller region: resolved via the standard chain (span.cloud_region > service_regions[service] > default_region).
  2. Callee region: the hostname is extracted from the HTTP target URL (e.g., order-api from http://order-api:8080/api/orders), then looked up in ctx.service_regions. If the hostname is not mapped, Perf Sentinel conservatively assumes same-region (no transport term).
  3. If both regions resolve and differ (case-insensitive comparison), the transport energy is computed and accumulated.

What triggers it. Two conditions must both be true for a span to contribute transport energy:

  • The span is an HTTP outbound call (event_type == HttpOut)
  • The span has a response_size_bytes value (from OTel http.response.body.size)

Report output. Transport CO2 appears as transport_gco2 in both CarbonReport and GreenSummary. It is included in the SCI total: total_mid = operational + embodied + transport. The field is omitted from JSON when zero, which is the case whenever no cross-region call carried a response size.

Config. No [green] key gates the transport term any more. include_network_transport and network_energy_per_byte_kwh are both deprecated and ignored since 0.9.25, parsed only to warn: gating the computation made two reports non-comparable, a configurable coefficient let two disclosures scale the same traffic differently, a zero silently erased the term, and a display toggle on a figure that is always disclosed had no remaining justification.

Hot path optimizations. The transport path runs inside the per-span scoring loop. Two micro-optimizations avoid allocations in the common case:

  • The hostname extracted from the URL is compared against service_regions with a probe-before-allocate pattern: to_ascii_lowercase() is only called when the hostname contains uppercase bytes (rare for Kubernetes/Docker service names).
  • The caller region reuses region_ref already resolved earlier in the same loop iteration instead of calling resolve_region again.

Top-offender co2_grams scalar. The per-offender co2_grams uses the flat ENERGY_PER_IO_OP_KWH constant, not the per-operation coefficients. When per_operation_coefficients is active (the default), co2_grams is set to None to avoid an inconsistency with the per-region breakdown. The top-offender ranking (by IIS) is unaffected since IIS counts operations, not CO2.

Limitations. See Limitations "Network transport energy" for the full discussion: wide estimate range, no CDN effects, no compression modeling, config-based region detection only, no last-mile modeling.

Energy state cache coherency

Both the Scaphandre scraper and the cloud SPECpower scraper publish per-service energy_per_op_kwh readings to the scoring path on every tick. The two states share an ArcSwap-backed storage in crates/sentinel-core/src/score/energy_state.rs. The two public types (ScaphandreState and CloudEnergyState) are thin newtype wrappers that delegate to AgedEnergyMap and keep their nominal identity for type-safe plumbing through the daemon.

The design is read-heavy and write-rare:

  • Writes: once per scrape interval (default 5s for Scaphandre, 15s for cloud energy) by a single task.
  • Reads: once per process_traces tick (typically multiple per second under real OTLP load).
  • Consistency: readers get the Arc that was current when they called load_full, writers do not block anyone.

ArcSwap was picked over RwLock<HashMap> because the process_traces reader path is on the hot loop, and the swap pointer-exchange is wait-free vs an RwLock that briefly blocks on read() when a writer holds the lock.

Confidence field on findings (planned perf-lint interop)

A confidence field is stamped on every Finding in the JSON and SARIF report, indicating the source context of the detection. The value is set by the pipeline caller: pipeline::analyze_with_traces sets CiBatch in batch mode when it detects a CI environment and LocalBatch otherwise (Confidence::batch_for_ci), and daemon::process_traces derives it from config.daemon.environment in streaming mode. Detectors themselves never reason about confidence. They emit Confidence::default() and the caller overrides it.

Values:

ConfidenceSourceSARIF rank
LocalBatchanalyze batch mode, no CI environment detected15
CiBatchanalyze batch mode, CI environment detected30
DaemonStagingwatch daemon with [daemon] environment="staging"60
DaemonProductionwatch daemon with [daemon] environment="production"90

The field surfaces in:

  • JSON report: every finding object includes "confidence": "local_batch" / "ci_batch" / "daemon_staging" / "daemon_production".
  • SARIF v2.1.0: per-result properties.confidence bag entry AND a standard SARIF rank value (0-100).
  • CLI terminal output: NOT displayed (the terminal stays clean for interactive use).

The planned consumer is perf-lint, a companion IDE integration (not yet published), which will import runtime findings from Perf Sentinel's JSON output and apply a severity multiplier based on the confidence. Any custom tooling consuming the same JSON or SARIF output can use the field the same way. See Integration "Finding confidence field" for the integration example.

Calibrating the proxy against a real wattmeter

calibrate.rs backs the calibrate subcommand, which turns a measured power CSV into per-service multipliers for the I/O proxy. It closes the gap between "a directional coefficient" and "a directional coefficient anchored on your hardware" without requiring an agent at runtime.

The arithmetic is plain: for each service present in both the traces and the readings, energy_per_op = total_energy / total_ops, and the emitted factor is that divided by the default proxy coefficient. A service with zero ops in the observation window is skipped rather than assigned a factor, since dividing by it would manufacture a number out of no evidence.

Readings must cover the service's own host share only. Database or broker power belongs to [green.alumet.database], [green.database_static], [green.alumet.broker] or [green.broker_static], and folding it into a service's reading counts it twice once a host source is declared.

Two input shapes are accepted and auto-detected from the CSV header. timestamp,service,power_watts is integrated into energy using the interval between consecutive readings per service, so interleaved services do not corrupt each other's intervals. timestamp,service,energy_kwh is taken as-is. Comment lines starting with # are skipped, because wattmeter exports routinely carry a preamble.

This does not make the figure a measurement. The result still depends on the count-based ratio and still carries a model tag (io_proxy_*+cal) saying it is a calibrated model rather than a reading. A calibration derived on one workload mix and applied to another inherits that mix's assumptions, which is why the factor is per service and not global.