Configuration reference
Perf Sentinel is configured via a .perf-sentinel.toml file. All fields are optional and have sensible defaults.
<img alt="CLI commands overview" src="https://raw.githubusercontent.com/Institut-du-Numerique-Responsable/perf-sentinel/main/docs/diagrams/svg/cli-commands.svg">
Contents
- Configuration fragments: deterministic multi-file loading.
- Subcommands: which subcommands read
.perf-sentinel.toml. - Sections: full per-section reference (
[thresholds],[detection],[green],[tempo],[daemon],[reporting]). - Minimal configuration: the smallest useful
.perf-sentinel.toml. - Full configuration example: every section populated with example values.
- Migration from 0.5.x: the 8 legacy top-level keys removed in 0.6.0 and how to migrate.
- Environment variables: which env vars override config-file values.
Configuration fragments
Perf Sentinel loads TOML documents from the directory .perf-sentinel.d/ beside the main config, then loads .perf-sentinel.toml last. This also applies when --config path/to/custom.toml is used: fragments come from path/to/.perf-sentinel.d/ and custom.toml remains the final override. The main file is optional only when --config is not supplied. Defaults are used only when neither the implicit main file nor any fragment exists. An unreadable file or an individual TOML parse error stops the command with exit code 75. After all overrides are applied, the merged configuration must pass typed deserialization and validation or the command also stops.
Fragment names must follow NN-lowercase-name.toml, where NN is a unique two-digit priority from 00 to 99. Files load in ascending priority order. Duplicate priorities, uppercase names and ambiguous separators are rejected. Non-TOML files in the directory are ignored.
When both values are tables, they merge recursively. Any other later value replaces the earlier value at the same key. The final merged document must still match the typed configuration schema. Since 0.12.0 that schema is strict: a key or a table name no section declares fails the load instead of being ignored, so a misspelled knob stops the command rather than silently leaving the default in place. The examples use these reserved bands:
| Priority | Purpose |
|---|---|
00 to 19 | shared defaults, thresholds and detection |
20 to 39 | energy sources and GreenOps measurement |
40 to 49 | carbon-intensity sources |
50 to 69 | daemon and deployment topology |
70 to 89 | reporting and organisation-specific policy |
90 to 99 | local overrides, preferably kept out of version control |
The ready-to-copy fragments in examples/ preserve their priority in their filename. Keep those names when copying them into .perf-sentinel.d/: 30-green-alumet.toml, 31-green-cloud.toml, 32-green-scaphandre.toml, 33-green-kepler.toml, 34-green-redfish.toml and 40-green-electricity-maps.toml. 60-daemon-docker.toml is a standalone main config for the collector and sharded Compose topologies. Mount it as .perf-sentinel.toml, then place only the optional GreenOps fragments in the sibling .perf-sentinel.d/ directory.
perf-sentinel watch adds one layer above every file. Its --listen-address, --listen-port-http, --listen-port-grpc and --max-export-findings flags join the merge as a last [daemon] document, so a flag replaces the same key from a fragment or from the main file, even when the file gives that key the wrong type. Validation runs once, on that result: a flag goes through the same bounds as the file, an out-of-range flag stops the daemon with exit code 75 and an error that names the command-line flags, and each advisory prints once, with the values the daemon runs with. A value clap itself rejects, such as a port above 65535, exits with code 2 before any file is read.
Subcommands
| Subcommand | Description |
|---|---|
analyze | Batch analysis of trace files. Reads from file or stdin |
explain | Tree view of a specific trace with findings annotated inline |
watch | Daemon mode: real-time OTLP ingestion and streaming detection |
query | Query a running daemon for findings, correlations or status. Colored text output by default, --format json for scripting. query inspect opens a live TUI |
demo | Run analysis on an embedded demo dataset |
bench | Benchmark throughput on a trace file |
pg-stat | Analyze pg_stat_statements exports (CSV/JSON or Prometheus) |
inspect | Interactive TUI to browse traces, findings and span trees |
diff | Compare two trace sets and emit a delta report (new/resolved findings, severity changes, per-endpoint I/O op deltas). Text/JSON/SARIF output |
report | Single-file HTML dashboard for post-mortem exploration in any browser. Accepts a trace file, a pre-computed Report JSON, or stdin via --input - (auto-detects array-of-events vs Report object, BOM-tolerant) |
tempo | Fetch traces from a Grafana Tempo HTTP API (single trace by ID or search-then-fetch by service) and pipe them through the analysis pipeline. Gated behind the tempo feature |
jaeger-query | Fetch traces from any Jaeger query API backend (Jaeger, Victoria Traces) and pipe them through the analysis pipeline. Gated behind the jaeger-query feature |
calibrate | Correlate a trace file with energy readings measured on each service's own host (Scaphandre, cloud monitoring CSV) and emit a TOML of I/O-to-energy coefficients to load via [green] calibration_file. A series shared by several services (same timestamps and values) is reported, since each factor would price the whole reading: keep it on one service or split it first |
Sections
[thresholds]
Quality gate thresholds. The quality gate fails if any rule is violated.
| Field | Type | Default | Description |
|---|---|---|---|
n_plus_one_sql_critical_max | integer | 0 | Maximum number of critical N+1 SQL findings before the gate fails |
n_plus_one_http_warning_max | integer | 3 | Maximum number of warning or higher N+1 HTTP findings before the gate fails |
n_plus_one_messaging_warning_max | integer | 3 | Maximum number of warning or higher N+1 messaging findings before the gate fails. Warning+ rather than critical-only, like HTTP: a Kafka client may already batch the publishes it buffers, so the occurrence count is an upper bound there |
io_waste_ratio_max | float | 0.30 | Maximum I/O waste ratio (0.0 to 1.0) before the gate fails |
min_usable_span_ratio | float | unset | Minimum share (0.0 to 1.0) of I/O-shaped spans that must be analyzable (SQL spans carrying db.statement, HTTP CLIENT spans carrying a full URL) before the gate fails, computed per I/O kind and reported as the worst of them so healthy HTTP traffic cannot mask a broken SQL surface. An I/O kind carrying fewer than 20 spans is left unjudged, too small a sample for a build-blocking ratio. When no kind clears that floor the rule is skipped and the report carries a tuning warning saying so. Batch runs only (analyze, report), the daemon exports no tally. Guards against a false green from unusable instrumentation: below the threshold the gate fails even with zero findings. Unset disables the rule. Applies to OTLP input only, which carries the per-reason filter tally (surfaced in analysis.ingest of the JSON report) |
[detection]
Detection algorithm parameters.
| Field | Type | Default | Description |
|---|---|---|---|
n_plus_one_min_occurrences | integer | 5 | Minimum number of occurrences (with distinct params) to flag an N+1 pattern |
window_duration_ms | integer | 500 | Time window in milliseconds within which repeated operations are considered an N+1 pattern |
slow_query_threshold_ms | integer | 500 | Duration threshold in milliseconds above which an operation is considered slow |
slow_query_min_occurrences | integer | 3 | Minimum number of slow occurrences of the same template to generate a finding. In the daemon it also applies to slow episodes within slow_query_window_minutes |
slow_query_window_minutes | integer | 15 | Daemon only. Window in minutes over which slow episodes of one (type, service, grouping, template) are counted across analysis batches. Slow spans within max(60 s, 1.5 x trace_ttl_ms) of an episode's first span count as one episode, so an isolated slow span or a short lock stays out of findings. 0 disables (range: 0-60) |
max_fanout | integer | 20 | Maximum child spans per parent before flagging as excessive fanout (range: 1-100000) |
chatty_service_min_calls | integer | 15 | A trace is flagged as a chatty service when its HTTP outbound calls exceed this value. Severity: warning > threshold, critical > 3x threshold. |
pool_saturation_concurrent_threshold | integer | 10 | Peak concurrent SQL spans per service within one trace to flag connection pool saturation risk. Uses a sweep-line algorithm on span timestamps. |
serialized_min_sequential | integer | 3 | Minimum sequential independent sibling calls (same parent, no time overlap, different templates) to flag as potentially parallelizable. |
grouping_attributes | list | ["k8s.namespace.name", "service.namespace"] | Resource or span attributes that separate one deployment from another, most specific first. The first one present on a span decides finding identity, so the same problem in two namespaces stays two findings. Every present attribute is captured and displayed. Max 8 entries, an empty list turns grouping off. Acknowledgment signatures ignore it. Since 0.19.0 the effective value is also the grouping label on the daemon's per-service metrics, see [daemon] per_grouping_labels. |
sanitizer_aware_classification | string | "auto" | How to classify SQL groups whose literals were collapsed to a placeholder (?, $?, %s, @param, :name) by an OTel agent or database driver. One of "auto", "strict", "always", "never". See note below. |
sanitizer_aware_min_cv | number | 0.5 | Coefficient of variation (standard deviation over mean) of the per-span durations above which the sanitizer heuristic reads a group as N+1 rather than a cached repeat. Range (0, 10]. See note below. |
sanitizer_aware_classification
OpenTelemetry agents and database drivers ship with SQL statement sanitization ON by default to keep PII out of trace attributes. The placeholder style depends on the stack: JDBC agents produce bare ?, PostgreSQL native drivers (pgx, asyncpg, sqlx) produce $1/$2 (normalized to $?), Python DB-API drivers produce %s, .NET drivers produce @p0/@Name, and Oracle/SQLAlchemy produce :name. In all cases the spans reach Perf Sentinel with the same template and no extractable parameters, so the standard distinct-params rule rejects the group and the redundant detector picks it up as redundant_sql instead of n_plus_one_sql. This setting controls the heuristic that recovers the correct classification:
"auto"(default): emitn_plus_one_sqlwhen either the ORM scope signal (Spring Data, Hibernate, EF Core, SQLAlchemy, ActiveRecord, GORM, Prisma, Diesel, Laravel/Eloquent, Doctrine, ...) or the per-span timing variance is high enough to indicate distinct row lookups. Otherwise leave the group to the redundant detector. Best recall on production Spring Data, EF Core and similar ORM stacks."strict": reclassify only when a primary signal (ORM scope marker, high occurrence >= 3 xn_plus_one_min_occurrences, or sequential siblings) fires conjointly with a corroborating signal (high timing variance or high occurrence). Preservesredundant_sqlprecision on moderate-count cached identical queries (legacy polling loops, unmemoized config lookups, typically 5-10 calls per request). Above the high-occurrence bar (default 15), any sanitized group fires regardless of ORM scope, sequential siblings, or variance, under thelooks_sanitizedguard. Use this when actionableredundant_sqlfindings should not be silently absorbed inton_plus_one_sql. The simulation lab runs all of its stacks this way, because underautoan ORM scope marker alone reclassifies a cache-warmed repeat of the same query as an N+1. The change of verdict only applies to moderate counts, since the high-occurrence bar above fires understrictas well."always": reclassify any sanitized group with at leastn_plus_one_min_occurrencesspans asn_plus_one_sql. Aggressive, may flip a real single-param redundancy."never": disable the heuristic entirely and fall back to the strictdistinct_paramscheck.
Findings reclassified by the heuristic (whether under "auto", "strict", or "always") carry classification_method = "sanitizer_heuristic" in their JSON representation so operators can spot where it is firing. Findings produced by the standard rule omit the field.
sanitizer_aware_min_cv
The timing-variance signal behind the modes above compares the coefficient of variation of the group's per-span durations to this threshold. Row lookups against different keys spread their durations across cache hits and misses, while repeats of one cached query cluster. The default of 0.5 favors reporting an N+1 over missing one, since a wrong call only swaps redundant_sql for n_plus_one_sql at the same avoidable-I/O weight.
Raise it on a runtime whose scheduling jitter spreads even cached repeats: PHP-FPM workers, CPU-throttled containers, shared CI runners. The simulation lab measured a CV of about 0.75 on ten identical Doctrine lookups served from cache under load. That is enough to cross the default and turn a redundant_sql finding into n_plus_one_sql with a remediation hint (leftJoin, with()) that does not apply to a repeat. At 1.0 the group keeps its redundant_sql verdict.
The same threshold feeds the HTTP heuristic, which decides whether a repeated outbound call with few distinct parameters reads as n_plus_one_http or redundant_http, so raising it moves both verdicts. What still reports a real N+1 whatever the variance depends on the mode and the path:
- SQL under
"auto": the ORM scope marker alone, so raising the threshold changes nothing on an ORM-instrumented group. - SQL under
"strict": the high-occurrence bar (3 x n_plus_one_min_occurrences). - HTTP: the direct rule, since distinct path or query parameters classify the group before the heuristic runs.
"always"ignores the variance entirely,"never"never consults it.
One value serves the whole configuration: a daemon in front of several runtimes picks the noisiest one's threshold and accepts the loss on moderate-count groups elsewhere.
The value is recorded in detection_config of every report. A report written before the knob existed reads back as 0.5, the value its run hard-wired.
[green]
See also. The Energy and SCI primer in the methodology doc defines SCI v1.0 (E + I + M terms), RAPL, Scaphandre, SPECpower, Boavizta and the Electricity Maps API used by the config sections below. Read it once if any term feels unfamiliar.
GreenOps scoring configuration aligned with SCI v1.0 (operational + embodied terms, confidence intervals, multi-region).
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Enable GreenOps scoring (IIS, waste ratio, top offenders, CO₂) |
default_region | string | (none) | Fallback cloud region used when neither the span's cloud.region attribute nor the service_regions mapping resolves a region. Examples: "eu-west-3", "us-east-1", "FR" |
embodied_carbon_per_request_gco2 | float | 0.001 | SCI v1.0 M term: hardware manufacturing emissions amortized per request (per trace), in gCO₂eq. Region-independent. A zero is deprecated since 0.9.25 and falls back to this default with a warning: no hardware has zero embodied carbon, and a zeroed coefficient erased the M term from the disclosure. The applied value is published in calibration_inputs |
use_hourly_profiles | boolean | true | When true, the scoring stage uses time-of-day-specific grid intensities for the 30+ regions with embedded hourly profiles. Regions with monthly x hourly profiles (FR, DE, GB, US-East) also account for seasonal variation. Reports are tagged model = "io_proxy_v3" (monthly x hourly) or "io_proxy_v2" (flat-year hourly). Set to false to pin reports to the flat-annual model |
hourly_profiles_file | string | (none) | Path to a JSON file with user-supplied hourly profiles. Can be absolute or relative to the config file. Profiles in this file take precedence over embedded profiles for the same region key. See "User-supplied profiles" below |
per_operation_coefficients | boolean | true | When true, the proxy model weights energy per I/O op by operation type: SQL SELECT (0.5x), INSERT/UPDATE (1.5x), DELETE (1.2x) and HTTP payload size tiers (small <10 KB: 0.8x, medium 10 KB-1 MB: 1.2x, large >1 MB: 2.0x). Does not apply when any measured backend provides the service's energy. Set to false to use the flat ENERGY_PER_IO_OP_KWH for all operations |
include_network_transport | boolean | ignored | Deprecated and ignored since 0.9.25: the transport term is always computed, always displayed and always disclosed, a display toggle on a published figure had no remaining justification. The key still parses, with a warning. The term needs response_size_bytes on HTTP spans (OTel http.response.body.size attribute) and the callee region mapped via [green.service_regions]. Same-region calls are excluded. Transport CO₂ appears as transport_gco2 in the JSON report |
network_energy_per_byte_kwh | float | ignored | Deprecated and ignored since 0.9.25. The coefficient is fixed at 0.04 kWh/GB so every disclosure scales transport identically, and the disclosure publishes the sourced 0.001-0.059 kWh/GB bracket beside it (see Limitations, network transport). The key still parses, with a warning |
[green.service_regions]
Per-service region overrides used when OTel cloud.region is absent from spans (e.g. Jaeger / Zipkin ingestion). Maps a service name to a region key. Service names match case-insensitively, so two keys that differ only in case and map to different regions are rejected at load.
[green]
default_region = "eu-west-3"
embodied_carbon_per_request_gco2 = 0.001
[green.service_regions]
"order-svc" = "us-east-1"
"chat-svc" = "ap-southeast-1"Region resolution chain
For each span, the carbon scoring stage resolves the effective region in this order (first match wins):
event.cloud_region: from the OTelcloud.regionresource attribute (or span attribute as fallback). Most authoritative.[green.service_regions][event.service]: per-service config override.[green] default_region: global fallback.
I/O ops with no resolvable region land in a synthetic "unknown" bucket with zero operational CO₂. The row appears in regions[] for visibility. Embodied carbon is still emitted because hardware manufacturing emissions are region-independent. The region cardinality is capped at 256 distinct buckets. Excess values fold into the unknown bucket to prevent memory exhaustion from misconfigured ingestion.
Output shape
When green scoring is enabled and at least one event is analyzed, the JSON report's green_summary includes:
co2: structured{ total, avoidable, operational_gco2, embodied_gco2 }object, plustransport_gco2,database_gco2andmessaging_gco2when present (the last two are the database and broker host carbon a daemon window counted insidetotal). Bothtotalandavoidableare{ low, mid, high, model, methodology }with 2× multiplicative uncertainty (low = mid/2,high = mid×2). Themethodologytag distinguishestotal("sci_v1_numerator+transport":(E × I) + M + Tsummed over traces, including whenTis zero) fromavoidable("sci_v1_operational_ratio": region-blind global ratio, excludes embodied and transport). A daemon window scored with a database or broker host source configured carries"sci_v1_numerator+transport+hosts"and"sci_v1_operational_ratio+hosts"instead, zero host energy included. Legacy reports can carry"sci_v1_numerator".modelvalues, most precise wins:"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,+calis appended (e.g."io_proxy_v2+cal"). The+calsuffix never applies to a measured tag.regions[]: per-region breakdown with{ region, grid_intensity_gco2_kwh, pue, io_ops, co2_gco2, intensity_source }, sorted byco2_gco2descending (highest-impact regions first) with alphabetical tiebreak.intensity_sourceis"annual","hourly","monthly_hourly"or"real_time"(Electricity Maps API) depending on which carbon intensity source was used for the region.
Carbon intensity data is embedded in the binary (no network egress). See 05 · GreenOps & carbon for the complete formula and methodology and Limitations for the directional / non-regulatory disclaimer.
User-supplied hourly profiles
Set [green] hourly_profiles_file to a JSON file to provide your own hourly profiles. This is useful for datacenter operators with their own power purchase agreements (PPAs) or for overriding the embedded data with local measurements.
{
"profiles": {
"my-datacenter": {
"type": "flat_year",
"hours": [45.0, 44.0, 43.0, "... 24 values total ..."]
},
"eu-west-3": {
"type": "monthly",
"months": [
[50.0, 49.0, "... 24 values for January ..."],
["... 11 more months ..."]
]
}
}
}User-supplied profiles take precedence over embedded profiles for the same region key. Validation at config load: each flat_year must have exactly 24 values, each monthly must have exactly 12 arrays of 24 values. All values must be finite and non-negative. If the region key exists in the embedded carbon table, a warning is logged when the profile mean deviates more than 5% from the annual value, but the profile is still accepted.
Hourly profile region aliases
Country-code aliases and cloud-provider synonyms are resolved to the same hourly profile. For example, "fr", "francecentral" and "europe-west9" all map to the eu-west-3 (France) profile. Notable mappings:
"us","eastus"->us-east-1(US-East, the most common US deployment region)"westeurope","nl","nl-ams"->eu-west-4(Netherlands)"northeurope","ie"->eu-west-1(Ireland)"uksouth","gb","uk","uk1"->eu-west-2(UK)"westus2"->us-west-2(Oregon)"gra11","gra","sbg","fr-par","outscale-eu-west-2"->eu-west-3(France)"waw1","pl-waw"->europe-central2(Poland)"bhs5","bhs"->ca-central-1(Quebec)
OVHcloud, Scaleway and OUTSCALE keys. OVHcloud names one datacenter three ways depending on which API you ask (GRA11 for the OpenStack Public Cloud region, GRA for the zone code, gra for the S3 location string), and all three are keyed. 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. An OUTSCALE deployment declares default_region = "outscale-eu-west-2" rather than the bare identifier, otherwise it scores against the British grid.
The full alias table is in score/carbon_profiles.rs. If your region key is not aliased, the flat annual value from the primary carbon table is used.
Every energy and grid-intensity backend is daemon-only. [green.alumet], [green.scaphandre], [green.kepler], [green.redfish], [green.cloud], [green.database_static], [green.broker_static] and [green.electricity_maps] are scraped by the watch daemon and by nothing else. A batch analyze or report run starts no scraper, so it scores with the I/O proxy estimate over embedded intensity data whatever these sections say, and it emits a warning naming the sections it had to ignore. Batch does not scrape because attributing power measured now to traces recorded earlier would produce a wrong figure rather than a missing one.
[green.scaphandre] (optional, opt-in)
Opt-in integration with Scaphandre for per-process energy measurement on Linux hosts with Intel RAPL support. When configured, the watch daemon spawns a background task that scrapes the Scaphandre Prometheus endpoint every scrape_interval_secs and uses the measured power readings to replace the fixed ENERGY_PER_IO_OP_KWH constant for each mapped service.
Prefer [green.alumet] for new deployments. Both integrations read the same RAPL counters, but Alumet's sampling is measurably less error-prone, as characterized by its own authors in Dissecting the software-based measurement of CPU energy consumption (Raffin et al.), and it attributes per cgroup rather than per process. Scaphandre support is kept for existing deployments, and alumet_rapl outranks scaphandre_rapl whenever both feed the same service. See Limitations.
| Field | Type | Default | Description |
|---|---|---|---|
endpoint | string | (none) | Full URL of the Scaphandre Prometheus /metrics endpoint. Must start with http:// or https:// (TLS supported via hyper-rustls). Required when the section is present |
scrape_interval_secs | integer | 5 | How often to scrape, in seconds. Valid range: 1-3600 |
process_map | table | {} | Maps Perf Sentinel service names (from span service.name) to a per-service ProcessMatcher (see below) |
Each process_map entry is a table with two fields: exe_contains (required, substring matched against the Scaphandre exe label) and cmdline_contains (optional, substring matched against the cmdline label). The matcher requires both substrings to be present when cmdline_contains is set. Exactly one Scaphandre process must match per entry, otherwise the scoring stage skips that service for the tick and emits a warn log naming the ambiguity. Entries whose matchers select the same process split its power by the operations each service emitted in the scrape window.
[green.scaphandre]
endpoint = "http://localhost:8080/metrics"
scrape_interval_secs = 5
[green.scaphandre.process_map."order-svc"]
exe_contains = "bin/java"
cmdline_contains = "order-svc.jar"
[green.scaphandre.process_map."chat-svc"]
exe_contains = "bin/java"
cmdline_contains = "chat-svc.jar"
[green.scaphandre.process_map."native-svc"]
exe_contains = "/opt/native-svc/bin/native-svc"Why both exe_contains and cmdline_contains. Scaphandre emits exe as an absolute path of the runtime (/usr/lib/jvm/.../bin/java, /usr/share/dotnet/dotnet). Several co-located services sharing a runtime (multiple JVMs, multiple .NET assemblies) collide on exe, and only cmdline discriminates them. Scaphandre also concatenates argv without separators: java -jar /tmp/order-svc.jar is emitted as cmdline="java-jar/tmp/order-svc.jar". Configure cmdline_contains with a substring that appears in this concatenated form (e.g. the jar/dll filename), NOT with a POSIX command line containing spaces.
Ignored in analyze batch mode. Only the watch daemon spawns the scraper. The analyze command always uses the proxy model regardless of this section.
Fallback behaviour. When the endpoint is unreachable, a service is not present in process_map or a service had zero ops in the current scrape window, the scoring stage falls back to the proxy model for those spans. The first failure logs at warn level, subsequent failures at debug to avoid spam. The perf_sentinel_scaphandre_last_scrape_age_seconds Prometheus gauge lets operators detect a hung scraper.
Precision bounds (important). Scaphandre improves the per-service energy coefficient but does not give per-finding attribution. RAPL is process-level, not span-level: two findings in the same process during the same scrape window share the same coefficient. See Limitations for the full discussion.
[green.kepler] (optional, opt-in)
Opt-in integration with Kepler (CNCF sandbox) for per-container or per-process energy measurement. Perf Sentinel reads Kepler 0.10 and later, which reads RAPL through sysfs, with hwmon as an experimental fallback since v0.12.0, and splits the active power between workloads by CPU time. When configured, the watch daemon scrapes Kepler's Prometheus /metrics endpoint, computes a per-service joules delta vs the previous scrape, and publishes a measured per-op coefficient tagged kepler_ebpf. The tag name is historical: since Kepler 0.10, the only version Perf Sentinel reads, the source is RAPL (sysfs/powercap, hwmon experimental), not eBPF.
| Field | Type | Default | Description |
|---|---|---|---|
endpoint | string | (none) | Full URL of the Kepler Prometheus /metrics endpoint. Required when the section is present |
scrape_interval_secs | int | 5 | How often to scrape, in seconds. Valid range: 1-3600 |
metric_kind | string | "container" | Which Kepler v2 counter to read: "container" (kepler_container_cpu_joules_total, keyed by container_name) or "process" (kepler_process_cpu_joules_total, keyed by comm) |
zone | string | "package" | Value of the zone label to read. Kepler emits one series per zone and the zones overlap, so only one counts. Rows without a zone label are kept |
service_mappings | table | {} | Maps Perf Sentinel service names to the Kepler label value identifying the same workload (container name for container, process command name for process). Services mapped to one value split its joules by ops |
auth_header | string | (none) | Optional "Name: Value" header. Prefer PERF_SENTINEL_KEPLER_AUTH_HEADER env var |
[green.kepler]
endpoint = "http://kepler.kepler.svc.cluster.local:28282/metrics"
scrape_interval_secs = 5
metric_kind = "container"
[green.kepler.service_mappings]
"order-svc" = "order-svc-deployment"
"chat-svc" = "chat"Ignored in analyze batch mode. Like Scaphandre, only watch spawns the scraper.
Counters sharing a label value are summed, zones are not. One container name repeated across pods (or one comm shared by several processes) yields several cumulative series under one mapping value. Their counters are summed before the per-window delta is computed, so the coefficient covers all of them together. Several services mapped to one value read that one sum: its delta is counted once and split between them by the ops each emitted in the window. Kepler also emits one series per zone, and the zones overlap: package already contains core and uncore. Only the rows whose zone label equals zone are read, as Kepler's metrics documentation advises. Rows without a zone label are kept. Where Kepler reads hwmon, zones are named after the sensors, so set zone to one of the values on the wire, otherwise the zero-sample warning fires.
Precedence vs Alumet and Scaphandre. Both outrank kepler_ebpf for the same service. All three read RAPL. Kepler gives each workload a share of the active power only, in proportion to its CPU time. Kepler is most useful on Kubernetes, where it runs as a DaemonSet and labels its series by container. No arm64 Kepler image is published as of v0.12.0, and a VM without RAPL gets no Kepler reading. See Limitations for the platform requirements and precision caveats.
Production deployment shape. Kepler typically runs as a Kubernetes DaemonSet, one pod per node. The current scraper performs a direct GET and the response must expose the Kepler series themselves. A Prometheus server's own /metrics endpoint exposes Prometheus internals, not the series it scraped. The example endpoint is the Service from upstream's manifests (namespace kepler, port 28282). That ClusterIP Service sends each scrape to an arbitrary Kepler pod, so it covers the whole cluster only when the cluster has a single node. For a multi-node cluster, run one Perf Sentinel per node or provide a federation/proxy endpoint that directly exposes the aggregated Kepler series in Prometheus exposition format. Native PromQL query mode is reserved for a later release.
[green.alumet] (optional, opt-in)
Opt-in integration with Alumet (INRIA/LIG, EUPL-1.2) for measured energy. Alumet is a modular measurement framework: a source plugin produces readings, optional transform plugins attribute them to workloads, and an output plugin exposes them. Perf Sentinel scrapes the prometheus-exporter output and supports only series derived from the rapl source (see below). When configured, the watch daemon publishes a measured per-op coefficient tagged alumet_rapl, which outranks every other measured source, including Scaphandre.
| Field | Type | Default | Description |
|---|---|---|---|
endpoint | string | (none) | Full URL of the Alumet prometheus-exporter /metrics endpoint (upstream default port 9091). Required when the section is present |
scrape_interval_secs | int | 5 | How often to scrape, in seconds. Valid range: 1-3600 |
metric_name | string | (none) | Prometheus metric name exactly as it appears on the wire, including the exporter's prefix/suffix. Required, no default (see below) |
label_key | string | (none) | Prometheus label carrying the workload identity. Required, no default. name for the k8s source (pod name), domain for a raw RAPL series |
energy_interval_secs | float | 1.0 | Wall-clock seconds the scraped joules value covers. Must match the poll_interval of the Alumet source feeding the metric (see below) |
service_mappings | table | {} | Maps Perf Sentinel service names to the Alumet label value identifying the same workload. Services mapped to one value split its energy by ops |
auth_header | string | (none) | Optional "Name: Value" header. Prefer PERF_SENTINEL_ALUMET_AUTH_HEADER env var |
Why metric_name and label_key have no default. Alumet's exporter prepends prefix and appends suffix (default _alumet) to every metric name, and the per-service series is produced by an energy-attribution formula whose name you choose. No default could be right for every deployment, and a wrong guess would scrape nothing. Read the names off your own endpoint:
curl -s http://localhost:9091/metrics | grep -i energymetric_name must name a RAPL series in joules. The scraper reads every value as the joules consumed during one energy_interval_secs and tags it alumet_rapl. Point it at rapl_consumed_energy itself, or at an energy-attribution formula fed by rapl, as in the example below. Alumet's other energy sources are not supported. The nvidia-nvml, amd-gpu and grace-hopper energy series are in millijoules and the nvidia-jetson input_power gauge is a power in milliwatts, so they would land in the wrong unit, 1000x too high for millijoules. energy-estimation-tdp publishes joules, but it is an estimate from the CPU's TDP, not a measurement. Scraped here, every one of these series would be published as measured RAPL under alumet_rapl. At start, the scraper logs a warning when metric_name looks like one of these series or ends in a unit other than joules.
Several rows per service are summed. Alumet's label_key is routinely shared: one pod carries a row per RAPL domain (package + dram), and label_key = "domain" on a dual-socket host carries one domain="package" row per socket. Every row sharing a label value is summed, which is the physically correct read since energy is additive (NaN and negative rows are skipped). Two consequences to configure for. Pick label_key so that the rows sharing a value are the ones you want added together. And make sure those rows do not overlap: RAPL domains nest (psys contains package, package contains pp0/pp1), so a formula that emits both a parent and its child domain for one label double-counts the shared share. package plus dram sums correctly, psys plus package does not. Several services mapped to one value, such as every service mapped to package under label_key = "domain", read that one sum and split it by the ops each emitted in the window, so the reading is counted once.
Why energy_interval_secs exists. This is the one field to get right. Alumet's exporter publishes every measurement as a Prometheus gauge holding the last flushed value, and rapl_consumed_energy is a CounterDiff: the joules burned during one source poll_interval, not a cumulative counter and not a power reading. Perf Sentinel divides by this interval to recover watts. The interval appears nowhere on the wire, so it must be declared here, and it must match the Alumet side. A mismatch rescales energy and carbon linearly and silently: declaring 1.0 while Alumet polls at 5s overstates energy 5x, with no warning. See Limitations. The daemon echoes the value it is using in the Alumet scraper started log line.
Matching Alumet config. Per-service attribution needs three Alumet plugins working together, rapl alone only measures the whole machine and procfs only identifies processes by PID:
# alumet-config.toml
[plugins.rapl]
poll_interval = "1s" # <- perf-sentinel's energy_interval_secs must equal this
[plugins.k8s]
# pod discovery, provides the `name` / `namespace` attributes
[plugins.energy-attribution.formulas.attributed_energy_cpu]
expr = "cpu_energy * cpu_usage / 100.0"
ref = "cpu_energy"
retention_time = "10s" # required, at least twice the slowest input poll_interval (k8s defaults to 5s)
[plugins.energy-attribution.formulas.attributed_energy_cpu.per_resource]
cpu_energy = { metric = "rapl_consumed_energy", resource_kind = "local_machine", domain = "package_total" }
[plugins.energy-attribution.formulas.attributed_energy_cpu.per_consumer]
cpu_usage = { metric = "cpu_percent", kind = "total" }
[plugins.prometheus-exporter]
port = 9091
suffix = "_alumet" # <- why the metric name below ends in _alumetThe matching Perf Sentinel side:
[green.alumet]
endpoint = "http://localhost:9091/metrics"
scrape_interval_secs = 5
metric_name = "attributed_energy_cpu_alumet"
label_key = "name"
energy_interval_secs = 1.0
[green.alumet.service_mappings]
"order-svc" = "order-svc-pod"
"chat-svc" = "chat-svc-pod"Ignored in analyze batch mode. Like every measured-energy backend, only watch spawns the scraper.
Precedence. alumet_rapl leads the measured chain, ahead of scaphandre_rapl. Both read RAPL, but Alumet's sampling is measurably less error-prone and it attributes per cgroup rather than per process. Running both on the same service is supported, and Alumet wins.
Upstream packaging gotchas. The upstream .deb ships /etc/alumet/alumet-config.toml (with sections for csv, procfs, perf, ...) and its alumet-agent wrapper points ALUMET_CONFIG at it unless the variable is already set. Enabling prometheus-exporter with --plugins works even though that file has no prometheus-exporter section. The agent fills the absent section from the plugin's defaults (prefix "", suffix "_alumet", port 9091). For a controlled config instead of the shipped one, point ALUMET_CONFIG at a fresh path so the agent regenerates defaults for your plugin set, or run config regen. In containers, the packaged binary carries file capabilities (cap_perfmon, cap_sys_nice, cap_sys_ptrace), so a plain docker run fails with EPERM unless those are granted with --cap-add.
The scraper alone does not put alumet_rapl in the report. Setting [green.alumet] starts the scraper (visible on /api/energy), but the measured coefficient only reaches green_summary when green scoring resolves a region for the spans ([green] default_region, [green.service_regions], or a cloud.region span attribute). Without one, per_service_energy_model keeps showing the proxy tag, which is easy to misread as a broken Alumet integration.
Alumet is pre-1.0 (v0.9.5 at time of writing). Metric names and plugin config may change between releases. If a scrape stops matching after an Alumet upgrade, the daemon warns with no samples matched the configured metric after three consecutive ticks. Alumet's development branch, past v0.9.5, appends the unit to every metric name that has one, so attributed_energy_cpu_alumet reads attributed_energy_cpu_alumet_joules there. When you move to a release that carries this change, update metric_name to match.
[green.alumet.database] (optional)
Declares one database workload measured by Alumet. A database emits no spans, so it can never appear in service_mappings (zero ops, the per-op coefficient path skips it). Instead, its energy over each scoring window is multiplied by the SQL-only waste ratio (avoidable_sql_io_ops / total_sql_io_ops) and reported as green_summary.database_waste. That host energy is counted inside energy_kwh and co2.total (as co2.database_gco2) once its carbon converts at the region below, and the figure then carries in_totals = true. The services' own figures never include it. The disclosure publishes it as the separate aggregate.database_waste block, a breakdown of what the totals already hold. Without this section the block carries the estimated form, the application-side share of the SQL spans, already inside the totals. See Methodology for the formula and Limitations for why it is a lower bound.
[green.alumet.database]
label_value = "postgres-pod" # value carried by label_key for the DB cgroup, verbatim
region = "eu-west-3" # else [green] default_region, one of them is requiredlabel_value is required, matched exactly like a service_mappings value. The database host energy needs a region to convert to gCO2, with the same region ids as [green.service_regions]: this region, the [green.database_static] one, or [green] default_region. With none of them the config fails to load, and when this section and [green.database_static] both set a region they must agree. The region is what lets the energy enter the totals: a figure whose carbon does not convert stays outside them. One database per config, declare the cgroup that serves your SQL traffic. Requires an agent on the database host, so it does not apply to a managed database. For a provisioned cluster, see [green.database_static].
Scope. The workload must run on hosts that no node-level application reading covers. Under label_key = "domain" with non-empty service_mappings the section is refused: a RAPL domain covers a whole node, so the database and the services on it would count the same joules. Measure per cgroup with another label_key, or drop one of the two. Next to non-empty [green.cloud.services] or [green.redfish] service_mappings, the section also needs [green.database_static]. If the database runs on hosts those backends read, remove this section, its energy is already in their readings. If it runs on separate hosts, add [green.database_static] with placement = "dedicated". A cloud service left on its provider's default CPU query warns at load, since that query returns the first series of the fleet, which may be the database host.
[green.alumet.broker] (optional)
The messaging twin of the section above. Declares one message broker measured by Alumet: a broker emits no spans of its own either, so its window energy is multiplied by the messaging-only waste ratio (avoidable_messaging_io_ops / total_messaging_io_ops) and reported as green_summary.messaging_waste, host energy counted inside the totals (as co2.messaging_gco2) under the same rule and published in the disclosure as aggregate.messaging_waste.
[green.alumet.broker]
label_value = "kafka-pod" # value carried by label_key for the broker cgroup, verbatim
region = "eu-west-3" # else [green] default_region, one of them is requiredThe broker host energy needs a region to convert to gCO2: this region, the [green.broker_static] one, or [green] default_region. With none of them the config fails to load, and when this section and [green.broker_static] both set a region they must agree. The scope rules of [green.alumet.database] apply too, with [green.broker_static] as the section that places the broker on separate hosts.
The same cgroup cannot feed two figures: a label_value that also appears in service_mappings, or that matches the database declaration, is rejected at config load. Requires an agent on the broker host, so it does not apply to a managed broker. For a provisioned cluster, see [green.broker_static].
[green.database_static] (optional, opt-in)
Declares the hosts of the database the traced services query, with no agent and no metric. This is the path for a provisioned database with a typed instance class and no host to instrument, such as an RDS or Aurora provisioned cluster or an Azure flexible server, or self-managed VMs without an agent. Serverless and capacity-unit databases have no provisioned node and cannot be declared.
[green.database_static]
placement = "dedicated" # required once any key is set: "dedicated" or "shared"
nodes = 2 # database hosts, readers and provisioned standbys included, 1 to 10000
instance_type = "db.r6i.large" # an embedded row or a managed class that resolves to one
region = "eu-west-3" # else [green.alumet.database] region, else [green] default_regionplacement says where the database runs, and every other key is checked against it at load, for every subcommand, analyze included. A section left without placement fails with a message naming both placements. The section takes no provider, the instance type alone prices the hosts.
dedicated: hosts of its own, which no traced service runs on.nodesandinstance_typego together. Both may be left out only next to[green.alumet.database]and withoutcpu_query, the shape for an Alumet-measured database that declares its placement.shared_withis refused.shared: a database pod on one application node.cpu_queryis required (the pod's CPU as a percent of that node),nodesis absent or 1, andinstance_typeis required whenshared_with(the[green.cloud.services]entries reading that node) is empty. A shared declaration has no ceiling: its CPU query bills the pod's share of the node, see A database on an application node below. A database spread over several application nodes cannot be declared shared, and a shared declaration is refused next to[green.alumet.database].
nodes counts every provisioned host of the cluster: the writer, each reader and each standby, a Multi-AZ standby included. A standby serves no query but draws power, so leaving it out under-counts the cluster. instance_type is 1 to 256 characters without control characters, and accepts the embedded rows and the managed class names listed in Managed service classes: db.r6i.large prices as r6i.large, db.m5d.large as m5.large and an Azure Standard_D4ds_v5 as Standard_D4s_v5. Cloud SQL db-n1-standard-N and db-n1-highmem-N price as their n1 row, Enterprise Plus db-perf-optimized-N-V as n2-highmem-V, and the Azure SQL standard-series vCore objectives (GP_Gen5_N, BC_Gen5_N, HS_Gen5_N, serverless GP_S_Gen5_N and HS_S_Gen5_N at their max vCores) at N vCores of the Skylake reference row. Names match case exactly. A type that resolves to no row fails the load, naming the type, the spelling that resolves when only the case differs, and the reason when it is a class left out on purpose (db.serverless, the Cloud SQL shared-core and dedicated-core custom tiers, the retired Azure SQL Fsv2 objectives). The database host energy needs a region: this section's, else [green.alumet.database]'s, else [green] default_region. Without one the load fails, and two declared regions that differ are refused.
A dedicated declaration with nodes and instance_type has a ceiling. Without cpu_query, the energy is nodes × max_watts × window duration, the E(n) = n × P_max ceiling of [green.broker_static] below, with the same three properties: it bounds compute and not wall power, it counts provisioned hosts and not their load, and it does not move when the application fixes an N+1. It is reported as green_summary.database_waste with model = "database_specpower" and enters the totals like a measured figure, as co2.database_gco2, converted at the region above. The disclosure counts its windows as declared, never as measured. What the ceiling leaves out on a database is in Limitations.
Hosts priced from their CPU. A dedicated declaration can also set cpu_query, a PromQL query returning the average CPU of the declared hosts as a percent, in one series. It needs [green.cloud]: the cloud scraper runs it against prometheus_endpoint, with the same auth header and interval, and bills nodes × (idle_watts + (max_watts - idle_watts) × cpu / 100) over each interval, tagged cloud_specpower. For a managed database read through cloudwatch_exporter:
[green.database_static]
placement = "dedicated"
nodes = 2
instance_type = "db.r6i.large"
cpu_query = 'avg(aws_rds_cpuutilization_average{dbinstance_identifier=~"orders-db-.*"})'A response with several series, an empty or non-finite result, or a failed fetch bills nothing that tick, warns once until the next good reading, and lets perf_sentinel_database_cpu_last_scrape_age_seconds grow. Once no reading has landed for three scrape intervals, the ceiling above bills the stretch under database_specpower, and the first reading after recovery, which reaches back over that stretch, is dropped. The query cannot be a provider default CPU query, [green.cloud] cpu_metric or the query of a [green.cloud.services] entry, which read application nodes, and it cannot sit next to [green.alumet.database]. Managed CPU metrics lag the hosts, see Limitations.
Hosts of different sizes. One instance type and one cpu_query cannot describe a writer and a smaller reader, or one cluster per tenant. A dedicated declaration then lists its groups of identical hosts as [[green.database_static.hosts]] entries, each with its own nodes, instance_type and optional cpu_query, and leaves those three keys out of the section itself:
[green.database_static]
placement = "dedicated"
region = "eu-west-3"
[[green.database_static.hosts]]
nodes = 1
instance_type = "db.r6i.xlarge"
cpu_query = 'avg(aws_rds_cpuutilization_average{dbinstance_identifier="orders-writer"})'
[[green.database_static.hosts]]
nodes = 2
instance_type = "db.r6i.large"
cpu_query = 'avg(aws_rds_cpuutilization_average{dbinstance_identifier=~"orders-reader-.*"})'Each group is a declaration of its own. It sets both nodes and instance_type, since the Alumet exception above does not apply to groups, and its cpu_query follows the rules of the previous paragraph. It bills, falls back to its ceiling and recovers on its own: a group whose query stops answering bills its ceiling while the others keep their readings. A group without cpu_query bills its ceiling. The database figure is their sum, tagged cloud_specpower while every group bills from its CPU and database_specpower as soon as one group bills its ceiling, so a fallback is never published as a reading. perf_sentinel_database_cpu_last_scrape_age_seconds reads the group that has gone longest without a reading. The load refuses hosts under placement = "shared" or next to [green.alumet.database], next to a top-level nodes, instance_type or cpu_query, with more than 256 entries or more than 10000 nodes in all, and two groups reading the same cpu_query. The section's region holds for every group.
A database on an application node. With placement = "shared", the database runs on a node that a [green.cloud.services] query group already reads, so its energy is inside that node's reading. cpu_query returns the pod's CPU as a percent of that node, in one series: filtered to the node the group's query reads, and normalized to one node by dividing a container's CPU rate by the node's vCPU count. shared_with lists that group exactly: every listed service resolves the same CPU query, every service resolving it is listed, and they share one power envelope, equal to instance_type's when it is set (instance_type may then be left out, the group's envelope prices the host). Each tick the probe bills the pod's dynamic share, (max_watts - idle_watts) × cpu / 100, capped at the node's own dynamic watts, and takes the same watts out of the node before its services split it. The node is counted once, and its idle stays with the services. When the database and the broker are both declared on one node, the database is billed first and the broker at most what is left. A tick without a one-series reading of the group's query bills the host nothing, and a shared host has no ceiling to fall back on, so a stale probe bills nothing either. The windows it bills carry model = "cloud_specpower". For a Postgres pod on the 4-vCPU node the orders and billing services read:
[green.cloud.services]
orders = { instance_type = "m6i.xlarge", cpu_query = '100 - (avg(rate(node_cpu_seconds_total{mode="idle",instance="node-a:9100"}[5m])) * 100)' }
billing = { instance_type = "m6i.xlarge", cpu_query = '100 - (avg(rate(node_cpu_seconds_total{mode="idle",instance="node-a:9100"}[5m])) * 100)' }
[green.database_static]
placement = "shared"
cpu_query = '100 * sum(rate(container_cpu_usage_seconds_total{container="postgres",node="node-a"}[5m])) / 4'
shared_with = ["orders", "billing"]The load refuses a shared_with with more than 256 entries, a duplicate, a name that is not a [green.cloud.services] key, services spread over several CPU queries, a group missing one of its services, services with different envelopes or an envelope different from instance_type's, and a service mapped in [green.redfish] or in [green.alumet] under label_key = "domain": those backends read the node whole, so the host is already inside their reading and must not be declared. A shared host with an empty shared_with next to [green.cloud.services], Redfish or Alumet domain mappings loads with a warning, since its share is billed on top of every reading: that is right only when no application reading holds its node, a node whose services Kepler reads per container for instance. A database spread over several application nodes cannot be declared shared, one node is one reading. Measure it per container (Kepler, an Alumet cgroup), or declare it dedicated only when its nodes host no traced cloud service.
Daemon only. When this section and [green.alumet.database] are both configured, the measurement wins: the ceiling bills only the stretches where the database's Alumet series is stale, under the arbitration [green.broker_static] describes below. One daemon declares the database: each daemon that declares it bills the whole cluster, so sharded daemons that each carry the section count the database once per daemon.
[green.broker_static] (optional, opt-in)
Declares a provisioned broker cluster, with no agent and no metric. This is the path for a provisioned broker cluster with a typed instance class and no host to instrument, such as a managed Kafka or MQ cluster, or self-managed VMs without an agent. Serverless and capacity-unit brokers have no provisioned node and cannot be declared.
[green.broker_static]
placement = "dedicated" # required once any key is set: "dedicated" or "shared"
nodes = 3 # provisioned broker nodes, 1 to 10000
instance_type = "kafka.m5.large" # an embedded row or a managed class that resolves to one
region = "eu-west-3" # else [green.alumet.broker] region, else [green] default_regionplacement says where the brokers run, and every other key is checked against it at load, for every subcommand, analyze included:
dedicated: hosts of their own.nodesandinstance_typego together. Both may be left out only next to[green.alumet.broker]and withoutcpu_query, the shape for an Alumet-measured broker that declares its placement.shared_withis refused.shared: a broker pod on one application node.cpu_queryis required (the pod's CPU as a percent of that node),nodesis absent or 1, andinstance_typeis required whenshared_with(the[green.cloud.services]entries reading that node) is empty. A shared declaration has no ceiling: its CPU query bills the pod's share of the node and takes it out of the node's reading, under the rules[green.database_static]gives for a database on an application node. A broker spread over several application nodes cannot be declared shared, and a shared declaration is refused next to[green.alumet.broker].
instance_type accepts the embedded rows and the managed class names listed in Managed service classes (kafka.m5.large, mq.m5.large). Names match case exactly. A type that resolves to no row fails the load, naming the type, the spelling that resolves when only the case differs, and the reason when it is a class left out on purpose. The broker host energy needs a region: this section's, else [green.alumet.broker]'s, else [green] default_region. Without one the load fails, and two declared regions that differ are refused. provider is accepted (aws, gcp, azure, scaleway or generic, anything else is refused) but prices nothing, the instance type alone prices the hosts, so setting it warns at load. A section left without placement fails with a message naming both placements.
A dedicated declaration with nodes and instance_type bills energy, and so does a shared one through its cpu_query. That energy enters the totals like a measured figure, as co2.messaging_gco2, converted at the region above. A cpu_query on a dedicated declaration prices the brokers from their CPU, tagged cloud_specpower with the ceiling below as its broker_specpower fallback, as [green.database_static] above describes, and perf_sentinel_broker_cpu_last_scrape_age_seconds tracks its scrapes. Brokers of different sizes take [[green.broker_static.hosts]] groups, under the rules [green.database_static] gives for hosts of different sizes, with broker_specpower as the declared tag.
The energy is nodes × max_watts × window duration, following E(n) = n × P_max: provisioned nodes times their power ceiling. Three properties to accept before relying on it:
- It bounds compute, not wall power.
max_wattsis the power at 100 % CPU, drawn from a SPECpower table that covers CPU and baseboard and excludes storage, network and PSU overhead. Those dominate on a broker, so this is a ceiling on the declared vCPUs and not on what the cluster pulls from the wall. A storage-bound Kafka node can draw more than this figure reports. - It counts provisioned infrastructure, not consumed. A three-node cluster is immobilized whether it runs at 10 % or 60 %. In the other direction, a stretch with no traffic bills at most one hour, so a mostly-idle cluster is under-counted.
- It does not react to application changes. Batching your publishes will not move this number, because nothing about it depends on traffic. If you want a figure that responds to remediation, you need the measured path.
Daemon only, like every measured path, since a window duration is needed. When both this section and [green.alumet.broker] are configured, the measurement wins. Precedence follows the broker's own Alumet series, not the scrape endpoint: a scrape that answers without carrying the declared label_value measures nothing, so the declaration takes over rather than being suppressed by a working endpoint. Energy the series already banked is delivered first, and the delta that lands when it recovers is dropped once, since it reaches back over wall clock the declaration billed.
[green.redfish] (optional, opt-in)
Opt-in integration with the Redfish BMC standard for chassis-level power readings on physical servers. Unlike Scaphandre and Kepler (which measure CPU + DRAM only), the BMC reading covers the whole chassis, fans, drives and network cards included. Whether power supply losses are included depends on where the BMC measures, see Limitations. Physical servers whose BMC the daemon can reach, any CPU architecture, no cloud VMs.
| Field | Type | Default | Description |
|---|---|---|---|
endpoints | table | (empty) | Map of chassis_id → endpoint table with url + schema. Required to activate the scraper. Two ids that resolve one chassis are rejected at load: same scheme, host (case ignored), port (the scheme default when absent) and /redfish/v1/Chassis/<id> path, so /Power and /EnvironmentMetrics of one chassis collide |
scrape_interval_secs | int | 60 | How often to scrape each chassis. Valid range: 15-3600 (BMC rate-limit defense) |
service_mappings | table | {} | Maps Perf Sentinel service names to the chassis hosting them. Every service mapped to the same chassis receives the same chassis-level coefficient. A load-time warning names a cloud CPU query that shares some services with a chassis but reads others it does not list: those keep a share of the node the chassis bills again |
ca_bundle_path | string | (none) | Reserved for a later release. Setting this field today causes the scraper to refuse to start with a clear error. A BMC certificate issued by a private CA, or a self-signed one that is not a CA certificate and whose subject alternative names cover the endpoint host, is trusted once it is in SSL_CERT_FILE (see Outbound proxy and private CA) |
auth_header | string | (none) | Curl-style Basic auth header. Prefer PERF_SENTINEL_REDFISH_AUTH_HEADER env var. Session-token auth (POST /SessionService/Sessions) is not yet supported |
Each endpoint table has two fields: url (string, full Redfish URL including path) and schema (string, "legacy_power", "environment_metrics" or "sensor"). The schema selects the canonical JSON pointer the parser uses, no operator-typed pointer involved:
schema | Path served by BMC | JSON pointer parser reads |
|---|---|---|
legacy_power | /redfish/v1/Chassis/{id}/Power | /PowerControl/{n}/PowerConsumedWatts, {n} the entry whose PhysicalContext is Chassis, else 0 |
environment_metrics | /redfish/v1/Chassis/{id}/EnvironmentMetrics | /PowerWatts/Reading |
sensor | /redfish/v1/Chassis/{id}/Sensors/{sensorId} | /Reading, if ReadingUnits is W and PhysicalContext is Chassis or absent |
[green.redfish]
scrape_interval_secs = 60
[green.redfish.endpoints."chassis-legacy-1"]
url = "https://bmc-rack-01.dc.example/redfish/v1/Chassis/1/Power"
schema = "legacy_power"
[green.redfish.endpoints."chassis-modern-1"]
url = "https://bmc-rack-02.dc.example/redfish/v1/Chassis/1/EnvironmentMetrics"
schema = "environment_metrics"
[green.redfish.service_mappings]
"order-svc" = "chassis-legacy-1"
"chat-svc" = "chassis-legacy-1"
"ledger-svc" = "chassis-modern-1"Which schema to choose. DMTF deprecated /Power (legacy_power) in Release 2020.4 in favor of PowerSubsystem, and the chassis wattage moved to EnvironmentMetrics.PowerWatts (environment_metrics). Implementing a deprecated resource is optional under the Redfish specification, so firmware support varies: upstream OpenBMC has not served /Power by default since 2025-08-26 and plans to remove the option, and some current firmware serves /Power without a PowerWatts reading in /EnvironmentMetrics. Take the chassis id from /redfish/v1/Chassis, query both resources on your BMC and keep the one that returns a number:
curl -sS -u "$BMC_USER" https://bmc.example/redfish/v1/Chassis | jq '.Members'
curl -sS -u "$BMC_USER" https://bmc.example/redfish/v1/Chassis/1/EnvironmentMetrics | jq '.PowerWatts'
curl -sS -u "$BMC_USER" https://bmc.example/redfish/v1/Chassis/1/Power | jq '.PowerControl'When the BMC certificate comes from a private CA, add --cacert with the CA file, the one the daemon trusts through SSL_CERT_FILE.
When neither returns a number, point a sensor endpoint at the chassis total-power sensor: the DataSourceUri of PowerWatts when present, otherwise the entry of /redfish/v1/Chassis/{id}/Sensors whose ReadingType is Power and that covers the whole chassis, with PhysicalContext Chassis or none. Power sensors of each supply, input or output, and of each CPU also report W. Pointing at one of them under-counts the chassis, and the daemon only rejects it when its PhysicalContext names something other than Chassis.
A mixed fleet is declared by giving each chassis the schema its firmware serves.
Ignored in analyze batch mode. Like Scaphandre and Kepler, only watch integrates Redfish.
Node-level coefficient. Every service mapped to the same chassis receives the same coefficient. Two services on one chassis will never get distinct measured per-op values via Redfish. See Limitations for the full discussion of this trade-off and the vendor-specific JSON response variance.
[green.cloud] (optional, opt-in)
Cloud-native energy estimation via CPU utilization + SPECpower interpolation. When configured, the watch daemon scrapes CPU% from a Prometheus/VictoriaMetrics endpoint and uses an embedded lookup table (idle/max watts per cloud instance type) to estimate per-service energy consumption. Supports AWS, GCP, Azure and on-premise hardware with manual watts override.
| Field | Type | Default | Description |
|---|---|---|---|
prometheus_endpoint | string | (none) | Prometheus HTTP API base URL (e.g. http://prometheus:9090 or https://prometheus:9090). TLS supported via hyper-rustls. Required. |
scrape_interval_secs | integer | 15 | Polling interval in seconds (range: 1-3600). |
default_provider | string | (none) | Default cloud provider: "aws", "gcp", "azure", "scaleway". Scaleway instance types are derived from its Product Catalog, see Instance types. |
default_instance_type | string | (none) | Fallback instance type for unmapped services. |
cpu_metric | string | (none) | Default PromQL query for CPU utilization, shared by every service without its own cpu_query. Those services read one node when it returns one series. |
Per-service entries in [green.cloud.services] support two forms:
Cloud instance (table lookup):
[green.cloud]
prometheus_endpoint = "http://prometheus:9090"
scrape_interval_secs = 15
default_provider = "aws"
[green.cloud.services]
"account-svc" = { provider = "aws", instance_type = "m7i.4xlarge", cpu_query = "aws_ec2_cpuutilization_average{instance_id=\"i-0123456789abcdef0\"}" } # Sapphire Rapids
"api-asia" = { provider = "gcp", instance_type = "c4d-standard-8" } # AMD Turin
"analytics" = { provider = "azure", instance_type = "Standard_D8s_v6" } # Emerald Rapids
"ml-bench" = { provider = "aws", instance_type = "m8g.4xlarge", cpu_query = "aws_ec2_cpuutilization_average{instance_id=\"i-0fedcba9876543210\"}" } # Graviton 4The full list of covered types, with their idle and max wattage, is Instance types. Modern instance families covered include AWS m7i/c7i/r7i, m7a/c7a/r7a, m6a/c6a, Graviton 2 m6g/c6g/r6g/t4g, Graviton 3 m7g/c7g/r7g and Graviton 4 m8g/c8g/r8g, GCP n1, c3, c3d, c4, c4d, n2d and t2a, and Azure Standard_Easv5, Standard_B (burstable, at full vCPU), Standard_Dv6, Standard_Dadsv6, Standard_Dpsv6 (Cobalt 100) and Standard_Ev6. One CPU-named bare-metal entry covers Sierra Forest (xeon-6780e, system-level watts assuming full chip ownership).
Managed classes. instance_type also accepts the names of managed database, cache, broker and search classes (db.r6i.large, cache.r5.large, kafka.m5.large, mq.t3.micro, r5.large.search) and AWS or Azure names carrying a storage or network modifier (db.m5d.large, c7gn.large, Standard_D4ds_v5). Each resolves to the row of the VM family it runs on, here, in [green.database_static] and in [green.broker_static]. The rules, and the classes left out with their reason, are in Managed service classes.
Services on one node. The services that resolve to one CPU query (their cpu_query, else cpu_metric, else their provider default, compared after trimming) are read as one node. When the response holds a single series and every one of those services resolves to the same idle and max watts, the node's watts are split between them by the operations each emitted in the interval, so the node is counted once. Services that read one query with different watt figures load with a warning naming both and keep a figure per service, each priced as if it owned the node. A query that returns several series also keeps a figure per service, each reading the first series, and the daemon warns once with the series count and the services. An aggregate query over a fleet (avg or sum without by) returns one series and is read as one node. Pricing each node therefore takes a query filtered to it, such as a node_exporter query filtered by instance (100 - (avg(rate(node_cpu_seconds_total{mode="idle",instance="node-a:9100"}[5m])) * 100)) or a cloud monitoring metric filtered on the label the exporter puts the instance id in (aws_ec2_cpuutilization_average{instance_id="i-0123456789abcdef0"} with cloudwatch_exporter). In the example above, account-svc and ml-bench would otherwise both read the AWS default query. Two different query strings are two nodes, even when they read the same machine.
Manual watts (on-premise or custom hardware):
[green.cloud.services]
"my-service" = { idle_watts = 45, max_watts = 120 }Ignored in analyze batch mode. Only the watch daemon spawns the Prometheus scraper.
Fallback behaviour. If the Prometheus endpoint is unreachable, the daemon falls back to the proxy model for all cloud-configured services. Unknown instance types fall back to a provider-level default.
Precision bounds. The SPECpower interpolation model has approximately +/-30% accuracy, better than the proxy model but less precise than Scaphandre RAPL. See Limitations for details.
[green.electricity_maps] (optional, opt-in)
Real-time carbon intensity from the Electricity Maps API. Daemon-only.
| Field | Type | Default | Description |
|---|---|---|---|
api_key | string | none | API auth token. Prefer PERF_SENTINEL_EMAPS_TOKEN env var for security |
endpoint | string | https://api.electricitymaps.com/v4 | API base URL (http:// or https://). v3 still works but emits a deprecation warning at startup |
poll_interval_secs | integer | 300 | Poll interval in seconds (range: 60-86400). Size it to your plan's quota |
emission_factor_type | string | lifecycle | Emission factor model. lifecycle (default) includes upstream emissions (manufacturing, transport). direct includes only combustion. Some Scope 2 frameworks prefer direct for stricter accountability |
temporal_granularity | string | hourly | API response aggregation. hourly (default), 5_minutes, or 15_minutes. Sub-hour values require a paid plan that exposes them, otherwise the API silently coarsens to hourly |
The region_map sub-table maps cloud regions to Electricity Maps zone codes. Regions match case-insensitively, so when a token enables the section, two keys that differ only in case and map to different zones are rejected at load:
[green.electricity_maps]
# Use PERF_SENTINEL_EMAPS_TOKEN env var instead of api_key in config
poll_interval_secs = 300
[green.electricity_maps.region_map]
"eu-west-3" = "FR"
"us-east-1" = "US-NY"
"ap-northeast-1" = "JP-TK"Staleness: if the last successful poll is older than 3x poll_interval_secs, the scraper falls back to embedded hourly profiles.
Plans and rate limits: the Electricity Maps free tier is for non-commercial use only, and its terms count any production system as commercial use. Commercial access is sold per signal and per zone, the real-time carbon intensity signal listing at €6,000 per zone per year in October 2026. Size poll_interval_secs to the quota of your plan: one zone polled every 3600 s spends about 720 requests a month. For French regions, [green.rte_eco2mix] below is a free alternative.
API version: the default endpoint targets v4 since Perf Sentinel 0.5.11. v3 remains accepted (the response schema is identical on carbon-intensity/latest), but a deprecation warning is logged once at daemon startup. To silence the warning, set endpoint = "https://api.electricitymaps.com/v4" explicitly. To keep v3 (for example to A/B-validate against v4), leave endpoint = "https://api.electricitymaps.com/v3" and acknowledge the warning.
Unknown values for emission_factor_type and temporal_granularity: these two knobs use a fail-graceful parser. A typo or unsupported value (e.g. temporal_granularity = "5min" instead of "5_minutes") does not reject the config at load time. The value is sanitized, a tracing::warn! is emitted, and the daemon falls back to the default. Watch the daemon logs at startup if you suspect a typo, the warn line will name the offending field and value.
Visibility in reports (since Perf Sentinel 0.5.12): the active scoring configuration (API version, emission factor type, temporal granularity) is surfaced in three places so Scope 2 reporters can audit which carbon model produced the numbers without reading the operator's TOML.
- The JSON report always carries
green_summary.scoring_configwhile GreenOps scoring is enabled: it records the applied coefficients and anelectricity_mapsflag. The API-specific fields are meaningful only when that flag istrue. - The HTML dashboard renders a chip bandeau above the green-regions table. Default values (
v4,lifecycle,hourly) are neutral chips, opt-in values (direct,5_minutes,15_minutes) are accent chips, the legacyv3endpoint shows as a warning chip mirroring the deprecation warning. Native browser tooltips explain each value. - The terminal
print_green_summaryoutput prepends a one-linerCarbon scoring: Electricity Maps v4, lifecycle, hourlybefore the per-region breakdown.
The Electricity Maps chips and terminal line are hidden when [green.electricity_maps] is not configured.
Grid operator feeds: [green.rte_eco2mix], [green.neso], [green.fingrid], [green.energinet], [green.ree_esios], [green.open_electricity], [green.mavir], [green.em6] (optional, opt-in)
Real-time carbon intensity published for free by grid operators themselves, for the cloud regions on their grid. Daemon mode only. A section being present enables its feed. All of them count direct emissions, lifecycle excluded, so they read lower than an Electricity Maps lifecycle figure for the same hour, and they do not measure exactly the same thing:
| Section | Operator, zone | What the number is | Step | Key | Licence |
|---|---|---|---|---|---|
[green.rte_eco2mix] | RTE (éCO2mix on ODRÉ), France | Direct emissions of French production, imports excluded | 15 min | none | Licence Ouverte v2.0 |
[green.neso] | NESO Carbon Intensity API, Great Britain (not Northern Ireland) | Direct CO2 of generation plus imports, per kWh consumed | 30 min | none | CC BY 4.0 and the API terms of use |
[green.fingrid] | Fingrid, Finland | Direct emissions of production (dataset 266, the RTE basis) or of consumption (265, imports at fixed yearly factors) | 3 min | free | CC BY 4.0 |
[green.energinet] | Energinet Energi Data Service, Denmark DK1 and DK2 | Direct CO2 per kWh consumed, imports included | 5 min | none | CC BY 4.0 |
[green.ree_esios] | REE e·sios, mainland Spain (not the Balearic or Canary Islands) | Direct CO2 of peninsular generation, imports excluded | 5 min | free token | e·sios legal notice: no commercial use, credit to Red Eléctrica |
[green.open_electricity] | Open Electricity (The Superpower Institute), Australia: NEM regions AU-NSW, AU-QLD, AU-SA, AU-TAS, AU-VIC and the WEM AU-WA | Direct emissions over regional generation, rooftop solar included, imports excluded | 5 min | optional, free | CC BY-NC 4.0 |
[green.mavir] | MAVIR, Hungary | Direct CO2 of domestic power plants per gross domestic generation, imports excluded | 15 min | none | no open licence |
[green.em6] | em6 (Energy Market Services, Transpower), New Zealand | Direct CO2 of grid-connected generation | 30 min | none | all rights reserved |
Shared fields:
| Field | Type | Default | Description |
|---|---|---|---|
regions | list | none | Cloud regions served by the grid, for every feed but energinet and open_electricity. Each gets the national value |
region_map | table | none | Cloud region to zone. [green.energinet]: DK1 (west) or DK2 (east). [green.open_electricity]: AU-NSW, AU-QLD, AU-SA, AU-TAS, AU-VIC or AU-WA |
endpoint | string | per feed | API base URL, http:// or https:// |
poll_interval_secs | integer | 900 (RTE), 600 (Open Electricity), 300 (NESO, Energinet, e·sios, MAVIR, em6), 180 (Fingrid) | Poll interval (range: 60-86400), about each operator's refresh step |
dataset | string | "production" | [green.fingrid] only: "production" (266) or "consumption" (265) |
api_key | string | none | Required by [green.fingrid] and [green.ree_esios], optional for [green.open_electricity]. Prefer the PERF_SENTINEL_FINGRID_API_KEY, PERF_SENTINEL_REE_ESIOS_API_KEY and PERF_SENTINEL_OPEN_ELECTRICITY_API_KEY environment variables |
[green.rte_eco2mix]
regions = ["eu-west-3"]
[green.neso]
regions = ["eu-west-2", "uksouth"]
[green.fingrid] # key in PERF_SENTINEL_FINGRID_API_KEY
regions = ["europe-north1"]
[green.energinet]
region_map = { "dk-west" = "DK1", "dk-east" = "DK2" }
[green.ree_esios] # token in PERF_SENTINEL_REE_ESIOS_API_KEY
regions = ["eu-south-2"]
[green.open_electricity]
region_map = { "ap-southeast-2" = "AU-NSW", "ap-southeast-4" = "AU-VIC" }
[green.mavir]
regions = ["hu-central"]
[green.em6]
regions = ["ap-southeast-6"]Precedence. Electricity Maps wins for any region it also covers. A cloud region may appear in one grid feed only, the load fails otherwise. A real-time row from a grid feed carries intensity_provider (rte_eco2mix, neso, fingrid, energinet, ree_esios, open_electricity, mavir, em6) in green_summary.regions[], absent for Electricity Maps so earlier reports keep their shape. The model tag is grid_operator_api when no region used Electricity Maps. The HTML dashboard adds one chip per feed to the carbon scoring bandeau, and the terminal a Carbon scoring: grid operator open data (...) line, as soon as one region drew on it: a configured section alone proves nothing, since batch runs start no scraper.
Freshness. Each operator has its own lag. The scraper rejects a latest record older than 3 hours (RTE, Open Electricity), 2 hours (NESO, em6) or 1 hour (Fingrid, Energinet, e·sios, MAVIR) as a stalled feed, and one dated more than 15 minutes ahead as not yet measured. After 3x poll_interval_secs without a fresh value, scoring falls back to the embedded profiles, as with Electricity Maps. Failures are counted per zone, so a dead DK2 is reported even while DK1 answers: a warning names the feed and the zone after 3 consecutive failures.
Disclosures. Declare the feed in the org config, carbon_intensity_source = "rte_eco2mix", "neso", "fingrid", "energinet", "ree_esios", "open_electricity", "mavir" or "em6" (or "mixed" across several), so a reader does not mix their basis with an Electricity Maps lifecycle figure. CC BY 4.0, CC BY-NC 4.0, Licence Ouverte and the e·sios notice ask for attribution where the figures are published: the HTML report names and links the operator on each row with the credit its terms ask for, a published disclosure should credit it too.
Restrictive terms. e·sios, Open Electricity, MAVIR and em6 publish free of charge but grant no open licence for every use, so their sections stay opt-in like the others and are documented with their terms:
- REE e·sios: the e·sios legal notice, which covers
api.esios.ree.es, states "Se prohíbe expresamente la utilización de estos datos y servicios con fines comerciales". Reuse follows the standard licence of article 4.2 b of Spanish law 37/2007: credit Red Eléctrica as the source, keep the meaning, give the date of the last update, never suggest that REE endorses the reuse, and use no REE or e·sios logo. - Open Electricity: data on the Community, Academic and Pro plans is under CC BY-NC 4.0, to be credited as "Data sourced from Open Electricity (The Superpower Institute), licensed under CC BY-NC 4.0." Commercial use needs the Enterprise plan, and the licence page lists internal use by a for-profit organisation in the course of its business among the uses the standard licence does not cover. Keys must stay secret.
- MAVIR: no open licence. The legal notice ("Jogi nyilatkozat") lets the news and information of the site be used within the applicable law, and reserves the public or commercial use of other content to MAVIR's permission.
- em6: the free current-intensity endpoint comes with no licence: Transpower New Zealand Limited reserves all rights, and its terms neither grant nor forbid reuse.
Perf Sentinel is AGPL-3.0 software, and the disclosures it produces are public, declarative and unpaid, published to reduce the carbon footprint of web applications: the project reads that use as non-commercial. Each operator still applies its own terms and its own definition of commercial use: read them against your deployment before enabling a feed.
Per feed:
- RTE
éCO2mix: 50,000 calls per user per month on ODRÉ, the default 900 s spends about 2,900.taux_co2is an estimate that RTE later replaces with consolidated figures. - NESO: the terms forbid selling, leasing or re-hosting access to the API, and the NESO logo needs prior approval: each daemon calls it for its own use. Each poll reads the last 3 hours of half-hour windows and takes the newest estimate (
actual), dated by its own window. NESO sometimes stops updatingactualand copies it onto each new window: when it stays identical on 4 consecutive windows while the forecast moves, or when no window has one, the newest window's forecast is used instead. - Fingrid: a free key from data.fingrid.fi (developer portal, "Open Data starter"), sent in the
x-api-keyheader, never in the URL, and trimmed of surrounding whitespace. Without a key the section is disabled with a warning, like Electricity Maps without a token; a key that is not printable ASCII fails the load, and anhttp://endpoint with a key logs a cleartext warning. The dataset scored is recorded asscoring_config.fingrid_dataset(266 or 265) and named on the HTML chip and the terminal line, since the two bases differ. Limits: 10,000 calls a day and one every 2 seconds per key. - Energinet: values are initial estimates that Energinet says it will generally not correct. Its API guide asks vendors not to poll from every unit of an application: keep one daemon per site rather than one per host.
- REE e·sios: a free personal token, requested from the "Solicitud de Token" icon of the e·sios site, sent in the
x-api-keyheader with theAcceptheader of the official request example (application/json; application/vnd.esios-api-v1+json). Without a token the section is disabled with a warning. Only indicator 10355, the peninsula, is read: the islands publish on another indicator, so map mainland regions only. The value comes in tCO2 per MWh and is multiplied by 1000, and a 0 counts as a missing point. Each poll asks for the last 3 hours, which also misses the CDN cache that answers known URLs without checking the token. No rate limit is published: the default 300 s makes one call per 5-minute point. - Open Electricity: the API publishes emissions (tCO2e) and energy (MWh) per region and technology, no intensity: Perf Sentinel divides the emissions by the generation, leaving out the net battery series, battery charging and pumped storage, which would count the same energy twice. Rooftop solar lands 30 to 100 minutes behind the rest, so each region is read at the newest interval rooftop solar covers, up to an hour and a half old on the WEM. A timestamp marks the end of its 5-minute interval, the AEMO convention. One call per network mapped (NEM, WEM): the default 600 s makes 288 calls a day with both, under the 500 of the free plan, and the key, when set, goes in an
Authorization: Bearerheader. - MAVIR: the data only exists as the XLSX export of chart 1000687, read by a minimal ZIP reader. The column is found by words of its Hungarian header (
aránya,tény,nélkül: the measured ratio without household and self-consumption solar, which MAVIR fills 25 to 40 minutes late). A reworded header is still found, and a header that no longer matches exactly one column fails the feed instead of reading another column. The unit is printed nowhere, neither in the export nor on the chart axes: tCO2 per MWh follows from MAVIR's own emissions and generation series, and a ratio outside 0.01 to 1.5 is rejected as a unit change. The quarter in progress is read, dated by its start, and MAVIR revises it by a few g/kWh once it closes.www.mavir.hudisallows robots and is never called. The data hostrtdwweb.mavir.hupublishes no robots file, and the daemon calls it once per poll under a User-Agent naming the project. - em6: one free endpoint returns the last three trading periods. The newest is read, dated by the start of its half hour and published a few minutes after it ends. The figure covers grid-connected generation only, without embedded generation or batteries.
[green] calibration_file (optional)
Path to a calibration TOML file generated by perf-sentinel calibrate. When present, per-service calibration factors are loaded at config time and multiply the proxy model energy per op. Does not affect energy from any measured backend. Next to a database or broker source ([green.database_static], [green.broker_static], [green.alumet.database] or [green.alumet.broker]), the load warns: the readings fed to calibrate must exclude those hosts, whose energy already enters the totals as a host term, or it counts twice.
[green]
calibration_file = ".perf-sentinel-calibration.toml"perf-sentinel calibrate input size limits. Both inputs are capped to protect against unbounded memory use: the --traces file is capped at 1 GiB (the fixed batch cap since 0.8.7, same as analyze) and the --measured-energy CSV is capped at 64 MiB. Calibrate exits with a clear error if either file exceeds its limit. 64 MiB is generous for thousands of RAPL samples per minute. If you need more, file an issue describing the workload.
[tempo]
The tempo subcommand runs in batch mode (not daemon), fetches traces from a Grafana Tempo HTTP API and pipes them through the standard analysis pipeline. This section holds one setting. No other subcommand uses it, but those that load the config validate it, so a value outside the range fails their load too.
| Field | Type | Default | Description |
|---|---|---|---|
fetch_concurrency | integer | 16 | Trace bodies read at once after the search, 1 to 64. A value outside the range fails the load. Lower it when a small querier runs out of memory, see Tempo integration. --fetch-concurrency overrides it for one run |
Its other settings are CLI flags: --endpoint is required, --max-traces defaults to 100 and is bounded to 1..=10000 (the client's own read ceiling, not Tempo's), alongside --trace-id, --service, --lookback, --from/--to, --sort and --auth-header. Run perf-sentinel tempo --help for the current list. Binaries up to 0.42.1 refuse a [tempo] table, as they do any unknown top-level table. The --config file also applies for everything else, thresholds and detection in particular, since the fetched traces go through the same pipeline.
[daemon]
Streaming mode (perf-sentinel watch) settings.
| Field | Type | Default | Description | |
|---|---|---|---|---|
listen_address | string | "127.0.0.1" | IP address to bind for OTLP and metrics endpoints. Use 127.0.0.1 for local-only access. Warning: setting a non-loopback address exposes unauthenticated endpoints to the network, use a reverse proxy or network policy | |
listen_port_http | integer | 4318 | Port for OTLP HTTP receiver and Prometheus /metrics endpoint (range: 1-65535) | |
listen_port_grpc | integer | 4317 | Port for OTLP gRPC receiver (range: 1-65535) | |
json_socket | string | "/tmp/perf-sentinel.sock" | Unix socket path for JSON event ingestion | |
max_active_traces | integer | 10000 | Maximum number of traces held in memory. When exceeded, the oldest trace is evicted (LRU). Range: 1 to 1,000,000 | |
trace_ttl_ms | integer | 30000 | Time-to-live for traces in milliseconds. A trace goes stale once no span has arrived for this long, and the sweep that evicts and analyses it runs on a ticker at half this value, so the effective deadline is this plus up to one tick. A span arriving in that gap lands in the stale trace and refreshes it rather than opening a new one, which matters to anyone replaying a trace id. Range: 100 to 3,600,000 | |
sampling_rate | float | 1.0 | Fraction of traces to analyze (0.0 to 1.0). Set below 1.0 to reduce load in high-traffic environments. Whole traces are kept or dropped on a hash of the trace id, so the per-trace detectors stay correct on what remains and ratios such as the I/O waste ratio sample numerator and denominator alike. The absolute counts (findings, occurrences, the perf_sentinel_* totals) then describe that fraction of the traffic, and a pattern present in a small share of the traffic can be sampled out entirely. Below 1.0 the daemon emits a tuning entry in Report.warning_details saying so, and 0.0, which this range accepts, gets its own message since no trace is analyzed at all. Sampling done by a collector upstream has the same effect and cannot be detected, see Helm deployment | |
max_events_per_trace | integer | 1000 | Per-trace cap applied independently to stored events (ring buffer), retained inbound endpoint contexts (endpoint plus optional parent link), retained consumer destinations, and span-ancestry entries (intermediate parent link plus optional resolved endpoint). A trace can hold all four bounded collections. Events and ancestry entries allocate progressively and use their respective rotation policies. Range: 1 to 100,000 | |
max_payload_size | integer | 16777216 | Maximum size in bytes for a single JSON payload (default: 16 MiB, raised from 1 MiB in 0.5.13 because a daemon snapshot from /api/export/report already exceeds 1 MiB on a modest cluster). Range: 1,024 to 104,857,600 (100 MB). The default sits at the upper inclusive boundary of the comfort zone. Since 0.8.7 this caps daemon network payloads only: batch subcommands (analyze, diff, report, explain, calibrate, pg-stat, bench) read local input files under a fixed 1 GiB cap instead | |
environment | string | "staging" | Deployment environment label. Accepted values: "staging" (default, medium confidence) or "production" (high confidence). Stamps every finding with the corresponding confidence field for downstream tooling (perf-lint planned). Case-insensitive. Any other value is rejected at config load | |
tls_cert_path | string | (absent) | Path to a PEM-encoded TLS certificate chain for the OTLP receivers. When set alongside tls_key_path, both gRPC and HTTP listeners use TLS. When absent, listeners use plain TCP. Each TLS listener caps concurrent in-flight handshakes at 128 (non-configurable) and drops peers that do not complete the handshake within 10 seconds | |
tls_key_path | string | (absent) | Path to a PEM-encoded TLS private key. Must be set together with tls_cert_path (both or neither). On Unix, the daemon warns if the key file is readable by group or others | |
api_enabled | boolean | true | Enable the daemon query API endpoints (/api/findings, /api/explain/{trace_id}, /api/correlations, /api/status). Set to false to disable the API while keeping OTLP ingestion and /metrics active | |
read_api_key | string | (absent) | Read-only key for the two GETs a write key gates, GET /api/acks and GET /api/incidents (since 0.20.0). Never satisfies a POST or DELETE, never adds a gate where no write key is set unless require_read_key is on, and must differ from [daemon.ack] api_key and [daemon.incidents] api_key, since a read key equal to a write key is that key. Hand it to Grafana and the Hub so nothing that only reads holds a key that can ack or fabricate an incident. A Hub that relays acks holds [daemon.ack] api_key as a second, separate credential, sent on those writes alone. 12 characters minimum. PERF_SENTINEL_READ_API_KEY wins over this value | |
require_read_key | boolean | false | Puts every GET (and HEAD) of the query API behind a key: /api/findings, /api/findings/{trace_id}, /api/explain/{trace_id}, /api/correlations, /api/export/report, /api/acks, /api/incidents, /api/status, /api/config and /api/energy answer 401 without one. Each route takes read_api_key or the write key its own handler already accepts: [daemon.ack] api_key everywhere except GET /api/incidents, which takes [daemon.incidents] api_key instead, as X-API-Key or Authorization: Bearer. Writes keep their own keys, and /metrics, /health, OTLP ingest and the JSON socket stay outside the gate. Off (default), the API behaves as before and read_api_key never adds a gate. true without a read_api_key (or PERF_SENTINEL_READ_API_KEY) is a config error, and so is true with [daemon.cors] allowed_origins = ["*"]. Every reader then needs the key: the Hub (AuthHeaderName=X-API-Key), Grafana datasources, perf-sentinel query --api-key-file, `curl …/api/export/report | perf-sentinel report, the live HTML report and the JetBrains plugin. **Do not turn it on for a daemon developers' IDEs read until the JetBrains plugin there sends a per-endpoint credential**: earlier plugin releases send none and get 401` on every refresh. See Query API |
allowed_hosts | array | [] | Host names the HTTP port (OTLP HTTP, /metrics, /health, /api/*) answers, against DNS rebinding: a web page whose name is rebound to 127.0.0.1 still sends that name in Host (:authority over HTTP/2). On a loopback listen_address, localhost and IP literals always pass, so do the names listed here, and any other name gets 421 Misdirected Request. On any other bind the check runs only once this list is set, since cluster DNS names vary. IP literals still pass then, which keeps kubelet probes (they send the pod IP) working. Each entry is a bare host name, a port is allowed and ignored, and matching ignores case and a trailing dot. A request with no Host at all (HTTP/1.0) passes: a browser always sends one. See Query API | |
max_retained_findings | integer | 10000 | Maximum number of recent findings retained in the daemon's ring buffer for the query API. Older findings are evicted when the limit is reached. Range: 0 to 10,000,000, where 0 disables the store entirely and reclaims its memory (recommended when api_enabled = false) | |
max_export_findings | integer | 1000 | Maximum number of findings carried by one /api/export/report snapshot. Separate from the /api/findings cap, which paginates a browsing API, where this one sizes an export: a store holding tens of thousands of findings ships a slice of its most recent, and the snapshot says so in warning_details. Raising it grows the response body, and the HTML rendered from it, by a few KB per finding. Past roughly 2000 the snapshot outgrows the 8 MiB body limit query inspect and query monitor fetch it with, and the daemon logs an advisory. Range: 0 to 100,000, where 0 exports the envelope alone (green figures, no findings). Since the exported quality_gate counts findings from that slice, at 0 its three finding-count rules pass whatever the daemon detected. The fourth, io_waste_ratio_max, reads green_summary, which no cap empties, so the verdict is not a blanket pass: it only stops reflecting the findings. That is enough to make 0 suit a liveness probe and not an alerting one. Overridable per run with watch --max-export-findings | |
max_retained_traces | integer | 50 | Maximum number of traces whose masked spans are retained so /api/export/report carries a span tree the HTML dashboard can draw. Without it the correlation window drops a trace's spans seconds after it completes and an exported report has findings with nothing to show around them. Costs memory in proportion to max_events_per_trace, hence the much smaller cap than max_retained_findings. Range: 0 to 10,000, where 0 retains none. Ignored (treated as 0) when api_enabled = false or max_retained_findings = 0, nothing could serve the trees | |
ingest_queue_capacity | integer | 1024 | Capacity of the ingestion channel: span-event batches buffered between the listeners and the event loop. Once full, ingestion applies backpressure to producers. Raise it to absorb burstier traffic at the cost of memory. Range: 1 to 1,048,576 | |
analysis_queue_capacity | integer | 1024 | Capacity of the analysis worker queue: evicted and expired batches awaiting detect+score. Once full, whole batches are shed and counted on perf_sentinel_analysis_shed_batches_total. Raise it to tolerate longer analysis bursts before shedding. Range: 1 to 1,048,576 | |
per_service_labels | boolean | true | Whether perf_sentinel_findings_total and perf_sentinel_slow_duration_seconds carry a service label (since 0.18.0). Cardinality is capped per daemon run (128 services on findings, 64 on the histogram), services past a cap fold into service="_other" so the totals stay exact. false makes the label empty on every series, restoring the pre-0.18 shape, and since 0.19.0 the histogram's unlabeled series is pre-warmed at startup only when per_grouping_labels is off too. An empty label is no label: an honor-labels scrape (the chart's ServiceMonitor) drops it and a plain scrape overwrites it with the target's name. With the knob off, the shipped dashboard's Service filter still lists services (from the per-service I/O counters) but selecting one shows 0 findings and no slow-span latency. Keep All or leave the knob on. Does not affect the per-service I/O counters (service_io_ops_total, service_avoidable_io_ops_total, service_analyzed_io_ops_total), per-service by construction | |
per_grouping_labels | boolean | true | Whether perf_sentinel_findings_total, perf_sentinel_slow_duration_seconds, perf_sentinel_service_io_ops_total, perf_sentinel_service_avoidable_io_ops_total and perf_sentinel_service_analyzed_io_ops_total carry a grouping label next to service (since 0.19.0). The label holds the value of the first attribute present from [detection] grouping_attributes (empty when the span carried none), without its attribute key, so configure a single key when two attributes can share a value. Cardinality is capped per daemon run on admitted (service, grouping) pairs, after the service caps (512 on the analysis side, 256 on the histogram, 4096 at ingest). A pair past a cap keeps its service and folds its grouping into grouping="_other", so sum by (service) still equals the 0.18.0 series. The caps count pairs, so what folds follows services x groupings (11 services in 20 namespaces is 220 pairs, 100 in 10 is 1000). false makes the label empty on every series, restoring the 0.18.0 shape. An empty label is no label, and since nothing attaches a grouping target label the series has none, so the shipped dashboard's Grouping filter reaches it under All only. Unlike per_service_labels, this knob also governs the three per-service I/O counters. Not named namespace because Prometheus Operator attaches a namespace target label and the chart's honorLabels: true would let the daemon's win | |
findings_stdout | boolean | true | Whether each finding is also written to stdout as one NDJSON line, for a log backend that tails the container (see Runbook). false keeps the findings in the query API, the archive and the [daemon.hub_export] stream only, and leaves the container log to the daemon's own messages on stderr: the setting for a daemon whose findings already reach PerfSentinelHub, where the stdout copy only costs CPU and node disk. Metrics, the ring buffer and every downstream export are unaffected. With neither [daemon.hub_export] nor [daemon.archive], the daemon warns at startup that its findings live in the bounded ring only, or nowhere when api_enabled = false or max_retained_findings = 0 | |
memory_high_water_pct | integer | 0 | Memory-pressure admission control, as a percentage of the cgroup v2 memory limit. When the working-set ratio (memory.current minus reclaimable inactive_file page cache, over memory.max) crosses this high-water mark, ingest is rejected with a retryable status (counted on perf_sentinel_otlp_rejected_total{reason="memory_pressure"}, state on the perf_sentinel_ingest_memory_pressure gauge), bounding RSS independently of queue depth. Ingest resumes once usage falls 5 percentage points below the mark (hysteresis, so ingest does not flap around the boundary) or after 60 seconds spent inside that band (one trace_ttl_ms when that is longer), since a working set that settles there would otherwise keep ingest shut for good. A working set that stays at or above the mark keeps ingest rejected. 0 disables the guard (default). Linux/cgroup-v2 only, inert elsewhere, and fails open if the cgroup becomes unreadable. Set 80-85 to leave headroom above a typical steady-state footprint. The guard polls at a fixed cadence, so size the mark such that the limit - mark margin exceeds the peak in-flight footprint (a sustained flood can outrun a thin margin, see RUNBOOK). Range: 0, or 6 to 100 (1-5 would put the hysteresis low bound at or below zero) |
Comfort zones and startup warnings
Daemon limits accept any value inside their hard bounds (rejected at config load), but perf-sentinel watch emits a one-shot WARN log at startup when a value falls outside the recommended comfort zone. The warning is informational: the daemon still runs. Use it to catch an unusual value set by mistake.
| Field | Comfort zone | Why values outside the zone are unusual |
|---|---|---|
max_payload_size | 256 KiB to 16 MiB | Smaller may reject legitimate OTLP batches. Larger increases ingest latency and RSS |
max_active_traces | 1,000 to 100,000 | Smaller triggers aggressive LRU eviction. Larger grows memory roughly linearly |
max_events_per_trace | 100 to 10,000 | Smaller truncates complex traces. Larger rarely improves detection quality |
max_retained_findings | 100 to 100,000 (or 0) | Smaller evicts findings before /api/findings can serve them. Larger holds a backlog. 0 disables the store and is silent |
trace_ttl_ms | 1,000 to 600,000 | Below 1s flushes traces before slow spans land. Above 10min keeps near-dead traces |
max_fanout | 5 to 1,000 | Smaller floods the findings store with noise. Larger suppresses most fanout detections |
Comfort zones judge the static value at startup. At runtime the daemon complements them with a settings advisor: when lifetime counters show a knob undersized for the observed load (queue sheds, ingest rejects, near-full trace window...), /api/export/report emits tuning entries in Report.warning_details naming the knob, its current value, and the suggested adjustment. See Metrics section "Warning kinds: transient vs sticky" for the rule table.
[daemon.correlation] (optional)
Cross-trace temporal correlation in daemon mode. When enabled, the daemon detects recurring co-occurrences between findings from different services or traces (e.g. "every time the N+1 in order-svc fires, pool saturation appears in payment-svc within 2 seconds").
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable cross-trace correlation. Requires watch daemon mode with sustained traffic to produce useful results |
window_minutes | integer | 10 | Rolling window in minutes over which co-occurrences and source occurrences are counted. Range 1 to 10080 (7 days), checked when enabled = true. Memory does not grow with occurrences in the window: pairing only keeps the findings analysed in the last lag_threshold_ms + 2 x trace_ttl_ms, so the pairing deque and the scan each finding makes over it scale with trace_ttl_ms (about a minute of findings at the default 30 s TTL), and the window only sets the span of per-pair and per-endpoint counters. Every distinct endpoint (template included) seen in the window is kept, so memory grows with the number of distinct endpoints in the window |
lag_threshold_ms | integer | 5000 | Maximum time lag in milliseconds between two findings to consider them co-occurring, measured between the findings' own first-span timestamps (first_timestamp), not the time the daemon analysed them. The finding with the earlier timestamp is the source |
min_co_occurrences | integer | 5 | Minimum number of co-occurrences over the window before a correlation is reported. Each source occurrence counts at most once per pair |
min_confidence | float | 0.7 | Minimum confidence score (0.0 to 1.0) to report a correlation. Computed as co_occurrence_count / source_total_occurrences, both counted over the same window |
max_tracked_pairs | integer | 10000 | Maximum number of finding pairs retained simultaneously. It bounds what the correlator keeps, not what one batch walks: a wide topology scans the cross product of the incoming findings and the pairing horizon whatever this is set to, so lowering it makes the daemon refuse more rather than allocate less. Pairs scale with finding types times services, so a handful of services can overrun the default. Past the cap the correlator evicts the lowest-count, stalest pairs, ties in pair order, so /api/correlations returns a subset with nothing on the output saying so. perf_sentinel_correlator_pairs_evicted_total is the signal, and the daemon logs a warning on the first eviction. Not comfort-zone checked at startup |
[daemon.correlation]
enabled = true
window_minutes = 10
lag_threshold_ms = 5000
min_co_occurrences = 5
min_confidence = 0.7A 24 h window suits patterns that recur over a day. It costs the same pairing memory as the default. Only the counters span longer:
[daemon.correlation]
enabled = true
window_minutes = 1440
lag_threshold_ms = 2000
min_co_occurrences = 10
min_confidence = 0.7
max_tracked_pairs = 50000Correlations are exposed via GET /api/correlations (when api_enabled = true) and snapshotted under correlations in GET /api/export/report. The daemon's stdout stream never carries them.
With [daemon.hub_export] on as well, the daemon pushes each pair's active periods to PerfSentinelHub 0.6.0 or later as episodes, from 0.30.0, so a correlation outlives the rolling window there. An episode closes once the pair's last co-occurrence has stood still for min(window_minutes, 30 minutes). [daemon.hub_export] below describes what travels.
[daemon.ack] (optional, since 0.5.20)
Daemon-side runtime ack store. Complements the CI TOML acks (see Acknowledgments) with a JSONL append-only file mutated through the HTTP API endpoints POST / DELETE /api/findings/{signature}/ack.
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Enable the daemon ack endpoints. When false, POST / DELETE / GET /api/acks return 503 Service Unavailable, and GET /api/findings skips the ack filter |
storage_path | string | <data_local_dir>/perf-sentinel/acks.jsonl | Override for the JSONL file location. Resolved at runtime via dirs::data_local_dir() (XDG on Linux, Library/Application Support on macOS) when absent. Error policy splits by source: an explicit override that fails to open is fatal at startup, a default path that cannot be resolved or opened only logs a WARN and leaves the two ack write routes returning 503 (GET /api/acks is auth-only and still answers 200 with an empty list). There is no /tmp fallback because the file holds audit data that must survive a reboot. Minimal containers without HOME (the published FROM scratch image, for one) fall in the second case, so set this explicitly there |
api_key | string | (absent) | Optional secret gating ack access. When set, POST and DELETE on /api/findings/{signature}/ack and GET /api/acks require the X-API-Key header, or an Authorization: Bearer carrying the same key, to match (constant-time compared via subtle, the GET also taking [daemon] read_api_key). GET /api/findings stays unauthenticated unless [daemon] require_read_key = true, except with ?include_acked=true, which carries the same audit trail and takes the same keys as GET /api/acks. Set the PERF_SENTINEL_ACK_API_KEY environment variable to override this and keep the key out of the committed config. The env var takes precedence when present, the same convention as PERF_SENTINEL_EMAPS_TOKEN. Empty string (or an env var set to empty) is rejected at config load |
toml_path | string | ".perf-sentinel-acknowledgments.toml" (CWD-relative) | Override for the CI TOML acks file. Read at startup, then re-read every minute so an edit applies without a restart, which matters when the file is a mounted ConfigMap. A failed re-read keeps the previous acks and logs a warning, and so does a file that has disappeared, so neither a half-written file nor an unmounted volume un-acknowledges anything. Only an explicitly configured path is polled, never the CWD-relative default, and enabled = false stops the poll along with the rest. Set an absolute path for systemd or container deployments where CWD is not the repo root |
[daemon.ack]
enabled = true
storage_path = "/var/lib/perf-sentinel/acks.jsonl"
# api_key = "<rotate-this>"
toml_path = "/etc/perf-sentinel/acknowledgments.toml"The JSONL file is replayed and atomically rewritten (via tmp + rename) at every daemon restart, so repeated ack / unack cycles cannot accumulate beyond their net active state. On Unix, the file is created with mode 0600 (owner read-write only).
[daemon.hub_export] (optional)
Bounded, asynchronous export of live findings to PerfSentinelHub. The daemon coalesces repeated findings by signature and keeps only their latest value, so a hot issue does not create one network request per detection.
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable Hub export. Detection remains non-blocking when the Hub is unavailable |
endpoint | string | (absent) | Hub URL ending in /api/import/findings |
source_id | string | (absent) | Hub source identifier: 1-64 ASCII letters, digits, ., _ or - |
api_key_file | string | (absent) | File containing the source import key. Required when enabled. The key must contain at least 32 characters |
batch_size | integer | 100 | Findings per request, from 1 to 100 |
flush_interval_secs | integer | 5 | Maximum normal batching delay, from 1 to 300 seconds |
request_timeout_secs | integer | 10 | How long one push may take before the daemon gives up and retries, from 1 to 60 seconds |
max_pending | integer | 10000 | Maximum distinct pending signatures, from 1 to 1,000,000 |
[daemon.hub_export]
enabled = true
endpoint = "https://hub.example.com/api/import/findings"
source_id = "production-a"
api_key_file = "/run/secrets/perf-sentinel-hub-api-key"
batch_size = 100
flush_interval_secs = 5
request_timeout_secs = 10
max_pending = 10000request_timeout_secs applies to the findings, incidents, correlations and detections pushes alike. Raise it for a Hub that answers slowly while it catches up, rather than let every push time out, back off up to five minutes and fill the pending table. A graceful shutdown still gives the drain 10 seconds, so a longer timeout can leave a push cut off there. One task sends the four kinds in turn, so while a push waits on the Hub the other kinds and the correlation reads wait with it: give the Hub the time it needs and no more.
The pending structure is a latest-value table, not an unbounded queue. A signature is sent immediately when first discovered or when its severity worsens, then refreshed at most hourly while it recurs. Both the pending table and the recent-success cache are capped at max_pending. Evicting from the recent-success cache is not a loss and is not counted, while a pending-table eviction, an oversized finding and a batch the Hub rejects with an unretryable 4xx all increment perf_sentinel_hub_export_dropped_total. Failed requests retain their coalesced batch and retry with exponential backoff and jitter. A daemon restart clears both caches. Requests and JSON bodies are bounded to 100 findings and 2 MiB. Use HTTPS outside a trusted private network.
With [daemon.incidents] enabled too, the daemon pushes its incidents to /api/import/incidents beside endpoint, with the same key and source id, from 0.29.0. An incident is sent when it is recorded, when its alert resolves and when the settle pass adds frozen findings to it, at most 10 per request and 4 MiB per body. Its table follows the findings one: the latest state per incident id, retried with the same backoff on its own clock, drained at shutdown. It holds at most max_pending or 1,000 incidents, whichever is lower, since one incident can carry a thousand frozen findings. An incident larger than 4 MiB alone is dropped, and the Hub's hourly poll can still copy it. A Hub that answers 404 or 405 on that route predates it: the daemon logs one warning and stops pushing incidents until it restarts, while findings keep flowing. An older Hub with sign-in on answers 401 instead, so the daemon keeps retrying and logs each retry: upgrade the Hub first. perf_sentinel_hub_export_incidents_pending and perf_sentinel_hub_export_incidents_dropped_total count what waits and what is dropped.
With [daemon.correlation] enabled too, the daemon pushes its cross-trace correlations to /api/import/correlations beside endpoint, with the same key and source id, from 0.30.0, as episodes: one per period during which a pair kept co-occurring. An episode opens when a pair's last co-occurrence (last_seen) is recent, follows it while it advances, and closes once it has stood still for the episode gap, min(window_minutes, 30 minutes), 10 minutes by default. A copy is sent when an episode opens, when its peak confidence or co-occurrence count rises, at most once a minute while it stays active, and when it closes, at most 100 episodes per request and 1 MiB per body. A graceful shutdown closes every open episode before the drain. The table holds the latest copy per episode, bounded by max_pending, and retries with the same backoff on its own clock. An episode larger than 1 MiB alone is dropped. A Hub that answers 404 or 405 on that route predates it: the daemon logs one warning and stops pushing correlations until it restarts, while findings and incidents keep flowing. An older Hub with sign-in on answers 401 instead, so the daemon keeps retrying and logs each retry: upgrade the Hub to PerfSentinelHub 0.6.0 first. perf_sentinel_hub_export_correlations_pending and perf_sentinel_hub_export_correlations_dropped_total count what waits and what is dropped.
With [daemon.detections] enabled too, the default, the daemon pushes its detection ledger to /api/import/detections beside endpoint, with the same key and source id, from 0.37.0. Each round sends every day whose counts moved since the Hub last accepted it, only the rows that changed since then, in pages shaped like a GET /api/detections page, at most 5,000 rows and 4 MiB each, the day's days entry on its last page. The Hub keeps the larger count of each row, so the unchanged ones need not travel again. Rounds start at most once a minute, or once per flush_interval_secs when that is longer, and a failed page is retried with the same backoff on its own clock. A graceful shutdown sets gap = true on the current day and pushes once more, after the findings, incidents and episodes still pending, so a long ledger never spends their drain budget. A page the Hub rejects with an unretryable 4xx is dropped, its rows counted in perf_sentinel_hub_export_dropped_total, and its day set to gap = true in the ledger. A Hub that answers 404 or 405 on that route predates it: the daemon logs one warning and stops pushing detection counts until it restarts, while the other exports keep flowing.
The Hub's reply to a detection page can carry watch, the signatures of threshold-type findings it wants confirmed: a fix marked done with no confirming day yet. The daemon keeps that list in memory, replaces it with each accepted reply, truncates it to max_signatures_per_day and counts below_threshold_passes for those signatures only. The list is lost on restart and comes back with the next push. A daemon the Hub only polls gets no list, so its rows never carry below_threshold_passes.
On a graceful shutdown (SIGTERM, helm upgrade, a rolling restart) the exporter flushes what it still holds before the process exits, for up to 10 seconds. The budget keeps an unreachable Hub from holding the daemon past the orchestrator's grace period, where the next signal is SIGKILL and nothing is flushed at all. When the budget expires, the findings still pending are dropped and a WARN names how many. Size terminationGracePeriodSeconds above that budget so the drain has room to run, otherwise the pod is killed mid-flush.
Mount the API key as a Secret-backed file. Do not put it in a ConfigMap or in .perf-sentinel.toml. With the Helm chart, use extraVolumes and extraVolumeMounts to expose the key at the configured api_key_file path.
[daemon.incidents] (optional, since 0.20.0)
Inbound incident webhooks, so an alert can hand the daemon the moment a service crashed or saturated and the daemon can freeze the findings of the window before the ring evicts them. See Query API for the endpoints. Daemon-only, off by default, and not to be confused with [daemon] memory_high_water_pct, which is the daemon's own cgroup.
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Expose POST and GET /api/incidents. An inbound write surface is opt-in. Both routes answer 503 when absent |
api_key | string | (absent) | Required when enabled, and a config error otherwise, because the route writes. Constant-time compared against X-API-Key or Authorization: Bearer, either one, 12 characters minimum, and GET also accepts [daemon] read_api_key. PERF_SENTINEL_INCIDENTS_API_KEY wins over this value |
lookback_ms | integer | 300000 | How far back of findings a posted incident freezes, 1000 to 86400000 |
max_retained | integer | 200 | Incidents kept in the in-memory ring, 1 to 1000, each carrying up to 1000 frozen findings. With archive_path set, the ring is reloaded from the archive at startup, up to this many incidents, so it survives restarts |
service_label | string | service | Alert label carrying the Perf Sentinel service name. An alert without it is refused, it being the join key to the findings |
kind_label | string | perf_sentinel_kind | Alert label carrying the kind: oom_kill, memory_saturation, restart, deploy or other. Anything else is other, never guessed from alertname |
namespace_label | string | namespace | Alert label carrying the namespace, the one kube-prometheus alerts already carry. Optional, never a reason to refuse: its value is carried on the incident as namespace, the namespace parameter of GET /api/incidents filters on it, and it restricts the freeze to that namespace, leaving out the findings whose k8s.namespace.name grouping attribute names another. That takes k8s.namespace.name among [detection] grouping_attributes, and an alert without the label freezes by service alone |
ignored_kinds | array | ["deploy", "restart"] | Kinds whose alerts are acknowledged and dropped: not frozen, not kept in the ring or the archive, not pushed to the Hub, counted in the intake body's ignored and in perf_sentinel_incidents_ignored_total{kind}. A rollout or a restart fires one for every service it touches, and each recorded freezes a window of findings for little a post-mortem reads. [] records every kind. Each value must be one of oom_kill, memory_saturation, restart, deploy, other, else the config fails to load |
archive_path | string | (absent) | Append every new incident, close and settle to this newline-delimited JSON file, opened at startup so a bad path fails the daemon. Absent means the in-memory ring is the only record, and a node-level memory event that kills the observed service often takes a co-located daemon with it. Append-only, last record of an id wins, created 0600 and chmodded back to it when a volume mount weakens the mode, refused when the file is not the daemon's to tighten, no rotation. Read back at startup, before the API serves, from its last 256 MiB at most: the last record of each id, the last max_retained to arrive, in the order the ring held them, lines that do not parse skipped with one warning, nothing re-appended. A line does not say whether it records or updates, so a close or settle whose record precedes that read, was dropped or fell to a smaller max_retained counts as an arrival where it sits |
[daemon.incidents]
enabled = true
# api_key = "<rotate-this>" # or PERF_SENTINEL_INCIDENTS_API_KEY
lookback_ms = 300000The window a posted incident freezes is [at_ms - lookback_ms, at_ms + 2 * trace_ttl_ms], resolved with both bounds, so seen_count and first_seen_ms on the frozen findings describe the window rather than the whole retained history. See Query API for why the window closes after the incident and for the settle pass that fills its tail.
[daemon.detections] (optional, since 0.37.0)
Exact per-day volume of what the daemon detected. For each UTC day, the daemon counts every detection by finding signature, so the figure stays exact where the [daemon] max_retained_findings ring evicts. The ledger lives in memory only, and a restart starts a new one. On by default. Daemon-only.
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Record every detection in the per-day ledger. false skips the ledger, and the bounds below are still checked |
retained_days | integer | 8 | Days of ledger kept in memory, today included, 2 to 31. The older days are dropped |
max_signatures_per_day | integer | 10000 | Distinct finding signatures tracked per day, 1 to 1000000. A new signature past it counts in the day's overflow, never in another signature. A signature longer than 1024 bytes or holding a control character is not tracked and counts in the overflow too. The ceiling keeps a typo from sizing the ledger past memory |
[daemon.detections]
enabled = true
retained_days = 8
max_signatures_per_day = 10000[daemon.cors] (optional, since 0.5.23)
Cross-origin resource sharing for the daemon's /api/* query endpoints. Disabled by default (no Access-Control-Allow-Origin header is emitted, the loopback-only posture is preserved). Enable when a browser client needs to call the daemon, typically the HTML report in live mode (perf-sentinel report --daemon-url <URL>, see HTML report).
Scope: the CORS layer is wired only on the /api/* query API sub-router. The OTLP ingest path (/v1/traces), Prometheus exposition (/metrics), and liveness probe (/health) are NOT exposed cross-origin even under wildcard mode. Browser pages cannot post traces, scrape /metrics, or hit /health regardless of allowed_origins, since browser clients have no legitimate use for those surfaces.
Read-endpoint exposure: every /api/* GET endpoint (/api/findings, /api/acks, /api/status, /api/correlations, /api/explain/*, /api/export/report) is unauthenticated unless [daemon] require_read_key = true, in line with the loopback-only posture pre-0.5.23. Once you whitelist an origin, any browser tab on that origin can read every finding signature, ack metadata, and trace export the daemon holds. Only whitelist origins you trust to view all daemon-resident data. Mixing untrusted origins with wildcard mode (["*", "https://x"]) is rejected at config load.
| Field | Type | Default | Description |
|---|---|---|---|
allowed_origins | array<string> | [] | List of origins permitted to call the daemon's /api/* surface. ["*"] is wildcard mode (development only, no credentials). A non-wildcard list whitelists exact origins. Each non-wildcard entry must be a full origin (scheme + host + optional port), no trailing slash |
Wildcard example (development):
[daemon.cors]
allowed_origins = ["*"]Production example (whitelist):
[daemon.cors]
allowed_origins = [
"https://reports.example.com",
"https://gitlab.example.com",
]Methods allowed: GET, POST, DELETE, OPTIONS. Headers allowed: Content-Type, X-API-Key. (X-User-Id is not advertised because the daemon does not enforce it server-side. The by field on an ack POST body is operator-attested only.) Preflight Access-Control-Max-Age: 120 seconds. Long enough to amortize the OPTIONS roundtrip across a typical interaction, short enough that a tightened whitelist takes effect on the next browser preflight without a forced refresh.
The CORS layer does not set Access-Control-Allow-Credentials: true, which is incompatible with ["*"] and unnecessary because the daemon auths via the X-API-Key header rather than cookies. Browsers running on a non-whitelisted origin receive responses without the Access-Control-Allow-Origin header and the request is blocked client-side without a daemon-side rejection.
Origins that fail to parse as a valid HTTP header value (typically a copy-paste with embedded control characters) are dropped at startup with a warn! log and the rest of the list is honored. If every entry is invalid, the layer is disabled entirely. If [daemon] api_enabled = false, the CORS layer is skipped (the /api/* sub-router is not mounted in the first place) and a warn! notes the unused config.
Since 0.5.27, combining allowed_origins = ["*"] with [daemon.ack] api_key also emits a startup warn!. Wildcard CORS plus an X-API-Key auth lets any browser origin replay a captured key through the daemon, even though no cookie or Allow-Credentials mode is in play. Whitelist explicit origins for production deployments where the API key is set.
[reporting]
Public-disclosure settings consumed by disclose, hash-bake and verify-hash. The whole section is optional. An absent section means the operator never asked for a periodic disclosure. Full walkthrough in Reporting, field reference in Schema.
| Field | Type | Default | Description |
|---|---|---|---|
intent | string | (unset) | internal, official or audited. Read at daemon startup only: audited makes the daemon refuse to start (not implemented), official requires org_config_path and validates it |
org_config_path | string | (unset) | Path to the organisation/scope/methodology TOML, required when intent = "official" |
confidentiality_level | string | (unset) | internal or public. Reserved, validated then unused: the published value comes from disclose --confidentiality |
disclose_output_path | string | (unset) | Reserved, no effect today, disclose --output is what writes the report |
disclose_period | string | (unset) | calendar-quarter, calendar-month, calendar-year or custom. Reserved, unused, see disclose --period-type |
disclose reads none of these: it takes --intent, --confidentiality, --period-type, --org-config and --output from the command line. This section still gates the daemon at startup through intent and org_config_path.
The [reporting.sigstore] sub-section holds the Sigstore endpoints, Rekor being the transparency log and Fulcio the certificate authority. Both are reserved: they are parsed and then unused. verify-hash delegates signature checking to the cosign binary and invokes cosign verify-blob without --rekor-url or --fulcio-url, so cosign follows its own configuration and setting either key here has no effect. Point a private Sigstore instance at cosign itself until this is wired up.
| Field | Type | Default | Description |
|---|---|---|---|
rekor_url | string | https://rekor.sigstore.dev | Rekor transparency log endpoint. Reserved. |
fulcio_url | string | https://fulcio.sigstore.dev | Fulcio certificate authority endpoint. Reserved. |
Minimal configuration
An empty file or no file at all uses all defaults. A minimal configuration for CI might only set thresholds:
[thresholds]
n_plus_one_sql_critical_max = 0
io_waste_ratio_max = 0.25Full configuration example
[thresholds]
n_plus_one_sql_critical_max = 0
n_plus_one_http_warning_max = 3
n_plus_one_messaging_warning_max = 3
io_waste_ratio_max = 0.30
# min_usable_span_ratio = 0.9 # unset = disabled, see the thresholds table
[detection]
n_plus_one_min_occurrences = 5
window_duration_ms = 500
slow_query_threshold_ms = 500
slow_query_min_occurrences = 3
slow_query_window_minutes = 15
max_fanout = 20
chatty_service_min_calls = 15
pool_saturation_concurrent_threshold = 10
serialized_min_sequential = 3
# Recovery heuristic for already-parameterized SQL: "auto", "strict",
# "always", "never". The variance bar below is what separates a real N+1
# from a cached repeat. Raise it on a jittery runtime such as PHP-FPM,
# where repeats of one cached query spread past 0.5 and read as N+1.
sanitizer_aware_classification = "auto"
sanitizer_aware_min_cv = 0.5
[green]
enabled = true
default_region = "eu-west-3"
[daemon]
listen_address = "127.0.0.1"
listen_port_http = 4318
listen_port_grpc = 4317
json_socket = "/tmp/perf-sentinel.sock"
max_active_traces = 10000
trace_ttl_ms = 30000
sampling_rate = 1.0
max_events_per_trace = 1000
max_payload_size = 16777216
# Optional: enable TLS on both gRPC and HTTP listeners.
# Both fields must be set together (or both absent for plain TCP).
# tls_cert_path = "/etc/tls/server-cert.pem"
# tls_key_path = "/etc/tls/server-key.pem"
api_enabled = true
max_retained_findings = 10000
max_retained_traces = 50
# Optional: tune the bounded queues (defaults shown). Raise under bursty
# load to reduce ingestion backpressure / analysis shedding.
ingest_queue_capacity = 1024
analysis_queue_capacity = 1024
# Optional: `service` label on findings and slow-span histograms (bounded
# by the daemon's cardinality caps). false restores the pre-0.18 shape.
per_service_labels = true
# Optional: `grouping` label next to `service` on the same series plus the
# per-service I/O counters (capped on admitted (service, grouping) pairs).
# false restores the 0.18 shape.
per_grouping_labels = true
# Optional: write each finding to stdout as one NDJSON line (default true).
# false when the findings already reach PerfSentinelHub.
findings_stdout = true
# Optional: reject OTLP ingest when cgroup memory crosses this percent of
# the container limit, bounding RSS against OOM. 0 disables (default).
# Linux/cgroup-v2 only. Set 80-85 to leave headroom above steady state.
memory_high_water_pct = 0
# Optional: cross-trace correlation (daemon mode only)
# [daemon.correlation]
# enabled = true
# window_minutes = 10
# lag_threshold_ms = 5000
# Optional: per-day ledger of detections, in memory (default on)
# [daemon.detections]
# enabled = true
# retained_days = 8
# max_signatures_per_day = 10000
# Optional: trace bodies `perf-sentinel tempo` reads at once (1 to 64)
# [tempo]
# fetch_concurrency = 16Migration from 0.5.x
Eight legacy top-level keys were deprecated in 0.5.26 and removed in 0.6.0. A 0.5.x config that still uses any of them now fails at load time with a migration message rather than silently falling back to the default. Update to the sectioned form below before upgrading.
| Removed (top-level) | Use instead | Section |
|---|---|---|
n_plus_one_threshold | n_plus_one_min_occurrences | [detection] |
window_duration_ms | window_duration_ms | [detection] |
listen_addr | listen_address | [daemon] |
listen_port | listen_port_http | [daemon] |
max_active_traces | max_active_traces | [daemon] |
trace_ttl_ms | trace_ttl_ms | [daemon] |
max_events_per_trace | max_events_per_trace | [daemon] |
max_payload_size | max_payload_size | [daemon] |
Migration example. Before (0.5.x):
n_plus_one_threshold = 5
listen_port = 4318
max_payload_size = 2097152After (0.6.0+):
[detection]
n_plus_one_min_occurrences = 5
[daemon]
listen_port_http = 4318
max_payload_size = 2097152Loading a 0.5.x file on 0.6.0 returns a ConfigError::Validation whose message names both the removed key and its replacement, so a single tail of the error stream tells you exactly what to edit.
Environment variables
Configuration files must never contain secrets. For sensitive values (API keys, tokens), use environment variables in your deployment tooling. Perf Sentinel reads a fixed list of them, each one overriding the matching config field when set and trimmed of a trailing newline, so a Kubernetes Secret can feed it directly:
| Variable | Overrides | Read by |
|---|---|---|
PERF_SENTINEL_EMAPS_TOKEN | the Electricity Maps api_key | daemon and batch |
PERF_SENTINEL_FINGRID_API_KEY | [green.fingrid] api_key | daemon and batch |
PERF_SENTINEL_REE_ESIOS_API_KEY | [green.ree_esios] api_key | daemon and batch |
PERF_SENTINEL_OPEN_ELECTRICITY_API_KEY | [green.open_electricity] api_key | daemon and batch |
PERF_SENTINEL_ACK_API_KEY | [daemon.ack] api_key | daemon and batch |
PERF_SENTINEL_INCIDENTS_API_KEY | [daemon.incidents] api_key | daemon and batch |
PERF_SENTINEL_READ_API_KEY | [daemon] read_api_key | daemon and batch |
PERF_SENTINEL_DAEMON_API_KEY | the --api-key-file of ack and of every query subcommand | CLI, sent as X-API-Key |
PERF_SENTINEL_DAEMON_URL | the --daemon URL of the ack and query commands | CLI |
PERF_SENTINEL_HUB_TOKEN | the token login stored, for hub | CLI, sent as Authorization: Bearer |
PERF_SENTINEL_HUB_URL | the --hub URL of login and hub | CLI |
The overrides apply on every run, batch commands and a run without any config file included, so a job that inherits the daemon's Secret must carry valid values too (the incidents key only counts once [daemon.incidents] enabled is set). A variable set to an empty string counts as set: an empty key is rejected at config load rather than silently ignored, so a Secret mounted empty fails every command that loads it, the daemon included, instead of opening the route. The data-source keys are the exception: an empty or blank PERF_SENTINEL_EMAPS_TOKEN, PERF_SENTINEL_FINGRID_API_KEY, PERF_SENTINEL_REE_ESIOS_API_KEY or PERF_SENTINEL_OPEN_ELECTRICITY_API_KEY counts as unset, since a missing key only disables its source (with a warning for Fingrid and e·sios, Open Electricity then calling anonymously) and opens nothing.
Outbound proxy and private CA
Four more variables shape every outbound HTTPS call, from the daemon (energy scrapers, Electricity Maps, the Hub export) and from the CLI (tempo, jaeger-query, pg-stat and mysql-stat with --prometheus, report with a Prometheus source, query, ack, login, logout, hub, verify-hash --url):
| Variable | Effect |
|---|---|
HTTPS_PROXY, https_proxy | Proxy for https:// destinations, reached over an HTTP CONNECT tunnel. Credentials in its URL (http://user:pass@proxy:3128) become a Basic proxy authorization |
ALL_PROXY, all_proxy | Proxy used when HTTPS_PROXY is unset, empty or not a usable URL |
NO_PROXY, no_proxy | Destinations reached directly: host names, domains with their subdomains, IP addresses, CIDR ranges, or * (localhost,.svc,10.0.0.0/8). Loopback is reached directly only when it is listed |
SSL_CERT_FILE | PEM bundle whose certificates are trusted next to the bundled Mozilla roots, typically the root CA of a TLS-inspecting proxy or of an internal PKI |
http:// destinations always connect directly, and HTTP_PROXY is ignored: those calls reach internal services, and a cluster-wide proxy must not divert them. The proxy itself is reached over plain HTTP, and TLS runs end to end between Perf Sentinel and the destination, inside the tunnel. Only an http:// proxy URL, or one without a scheme, is used: a SOCKS or https:// proxy URL logs one warning per process and the call connects directly. The uppercase spelling of each variable is read first. The proxy variables are ignored when REQUEST_METHOD is set, the CGI guard of the underlying library. An SSL_CERT_FILE that cannot be read, or holds no certificate, logs a warning and leaves the bundled roots alone.
A daemon or a CI job that already inherits HTTPS_PROXY, from a platform-wide setting or an injecting webhook, sends its https:// calls through that proxy. An https:// destination the proxy cannot reach, such as an in-cluster PerfSentinelHub, a Redfish BMC or a daemon queried over https://localhost, belongs in NO_PROXY.
With the Helm chart, set them through extraEnv and mount the bundle from a ConfigMap or a Secret:
extraEnv:
- name: HTTPS_PROXY
value: http://proxy.corp.example:3128
- name: NO_PROXY
value: localhost,.svc,.cluster.local,10.0.0.0/8
- name: SSL_CERT_FILE
value: /etc/perf-sentinel/ca/corp-root.pem
extraVolumes:
- name: corp-ca
configMap:
name: corp-root-ca
extraVolumeMounts:
- name: corp-ca
mountPath: /etc/perf-sentinel/ca
readOnly: trueAcknowledgments file
.perf-sentinel-acknowledgments.toml is a separate file from .perf-sentinel.toml. It lives at the root of the application repo and lists findings the team has accepted as known. Acknowledged findings are filtered from the CLI output (analyze, report, inspect, diff) and excluded from the quality gate.
Loading rules:
- The default path is
./.perf-sentinel-acknowledgments.tomlin the current working directory. Override with--acknowledgments <path>. - If the file does not exist, the run is a no-op (no error, no output noise).
--no-acknowledgmentsskips the file entirely (audit view).- A typo in
signature, a missing required field, or a malformedexpires_atfails the run loud rather than silently widening the matched set.
Minimal entry:
[[acknowledged]]
signature = "redundant_sql:order-service:POST__api_orders:cafebabecafebabecafebabecafebabe"
acknowledged_by = "alice@example.com"
acknowledged_at = "2026-05-02"
reason = "Cache invalidation pattern, intentional. See ADR-0042."The expires_at = "YYYY-MM-DD" field is optional. Omitting it makes the ack permanent. Setting it lets you require a periodic re-evaluation: when the date passes, the ack stops applying and the finding reappears in the next CI run.
There is no glob or wildcard support, each entry is matched against an exact signature. Signatures are emitted on every finding in the JSON output, copy-paste them into the file rather than recomputing the SHA-256 prefix by hand.
For the full workflow and FAQ, see Acknowledgments.