]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ui-server): add opt-in Prometheus /metrics endpoint (closes #851) (#1912)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Sat, 20 Jun 2026 18:47:27 +0000 (20:47 +0200)
committerGitHub <noreply@github.com>
Sat, 20 Jun 2026 18:47:27 +0000 (20:47 +0200)
* feat(ui-server): add opt-in Prometheus /metrics endpoint (closes #851)

Expose simulator state on GET /metrics when uiServer.metrics.enabled=true.
The endpoint is grafted into UIHttpServer.requestListener after the
existing runRequestPrologue (access policy + rate limit) and authenticate
calls, so it inherits per-client rate limiting, host/origin/proxy
validation and basic-auth credentials with no additional surface.
Prometheus is HTTP-only by spec; the flag is honoured only on
uiServer.type=http and AbstractUIServer.warnIfMisconfigured logs a
warning otherwise.

Metric families (exhaustive within the data already exposed via
ChargingStationData and Bootstrap.getState()):

* Global: simulator_info{version}, simulator_started,
  simulator_charging_station_templates_total,
  simulator_charging_stations_{configured,provisioned,added,started}_total,
  simulator_ui_server_known_stations_total,
  simulator_template_{added,configured,provisioned,started}{template}.
* Per charge station (label hash_id): simulator_station_info{vendor,
  model, firmware_version, ocpp_version, current_out_type},
  simulator_station_started, simulator_station_ws_state (numeric 0-3),
  simulator_station_connectors_total, simulator_station_evses_total,
  simulator_station_max_power_watts, simulator_station_max_amperage_amperes,
  simulator_station_voltage_out_volts,
  simulator_station_data_timestamp_seconds,
  simulator_station_boot_status_info{status},
  simulator_station_boot_heartbeat_interval_seconds,
  simulator_station_atg_enabled,
  simulator_station_diagnostics_status_info{status},
  simulator_station_firmware_status_info{status},
  simulator_station_ocpp_config_keys_total.
* Per connector (labels hash_id, connector_id):
  simulator_connector_status_info{status} (one-hot over
  ConnectorStatusEnum), simulator_connector_boot_status_info{status},
  simulator_connector_availability_info{availability},
  simulator_connector_error_code_info{error_code},
  simulator_connector_type_info{connector_type},
  simulator_connector_locked,
  simulator_connector_transaction_{started,pending,remote_started},
  simulator_connector_transaction_seq_no,
  simulator_connector_transaction_event_queue_size,
  simulator_connector_transaction_id (numeric value only, never label),
  simulator_connector_transaction_start_seconds,
  simulator_connector_transaction_energy_active_import_register_wh,
  simulator_connector_energy_active_import_register_wh,
  simulator_connector_max_power_watts,
  simulator_connector_charging_profiles_total,
  simulator_connector_evse_id, simulator_connector_reservation_active.

PII reject-list (HARD): supervisionUrl, chargeBoxSerialNumber,
chargePointSerialNumber, meterSerialNumber, iccid, imsi,
authorizeIdTag/localAuthorizeIdTag/transactionIdTag/transactionGroupIdToken
and OCPP customData. transactionId / remoteStartId never used as labels
(unbounded cardinality). Adversarial label values are escaped per
prom-client's exposition-format rules so synthesized # HELP / # TYPE
lines cannot be injected.

Cardinality soft cap: a single warning log per scrape when total
samples exceed METRICS_SOFT_SAMPLE_CAP=5000. The response is still
served in full so operators retain observability while being alerted
to fleet growth.

Adds prom-client@^15.1.3 and 17 new tests under
tests/charging-station/ui-server/UIMetricsEndpoint.test.ts covering
end-to-end exposition, security inheritance (access policy / rate
limit / basic-auth), the PII reject-list, exposition-format escaping
and the soft-cap warning. README.md updated with a "Metrics endpoint
(Prometheus)" subsection (config example, sample output, basic-auth
compatibility note, privacy/cardinality note, scrape_config snippet).

Refs: #851

* fix(ui-server): rebuild metrics registry on start to recover from stop/start cycle (M1)

The metrics registry was built only in the constructor and cleared on
stop(); after Bootstrap.stopUIServer() then startUIServer() (live-reload
path on uiServer.enabled toggle), metricsRegistry was undefined and
/metrics silently fell through to the JSON-RPC parser as a 400
Malformed URL with no log to indicate misconfiguration.

Move the registry build into start() guarded by an idempotency check
so the endpoint recovers across restart cycles. The constructor no
longer touches metricsRegistry.

Refs: #851 (Phase 4 review M1)

* feat(ui-server): expose metrics.softSampleCap in canonical defaults (M3)

The soft cardinality cap was documented as something operators may
'scale' but was not in the canonical defaults map. Add an optional
softSampleCap to UIServerMetricsConfigurationSchema (default 5000 via
METRICS_SOFT_SAMPLE_CAP) so operators can tune the threshold without
patching code, per AGENTS.md options-and-configuration rule.

Refs: #851 (Phase 4 review M3)

* fix(ui-server): serialize concurrent /metrics scrapes (M2 + m6 + m5)

Two concurrent GET /metrics requests interleaved on the shared
metricsSampleCount field and shared Gauge state, making the soft-cap
warning unreliable and the body content racy. Serialize scrapes via a
per-server promise chain; stop() now awaits the in-flight scrape
before clearing the registry, so a request mid-await no longer sees
undefined gauges.

Also adds the headersSent / writableEnded guard on the resolve path
so a client that closes mid-await does not trigger an emit-after-end.
The cap value is now read from uiServerConfiguration.metrics?.softSampleCap
(falls back to METRICS_SOFT_SAMPLE_CAP), wiring up the M3 schema field.

Refs: #851 (Phase 4 review M2 + m6 + m5)

* fix(ui-server): align iterateConnectors with simulator_station_connectors_total either-or (m2)

The per-connector iteration helper unconditionally yielded entries from
both data.connectors and data.evses, while simulator_station_connectors_total
uses an either-or rule. In current production these arrays are mutually
exclusive (buildConnectorEntries returns [] when hasEvses=true), so the
collision was unreachable, but the inconsistency made the helper fragile
to future invariant changes. Align it with the gauge's logic.

Refs: #851 (Phase 4 review m2)

* fix(ui-server): accept HEAD on /metrics for liveness-probe scrapers (n3)

Some Prometheus tooling HEAD-probes the endpoint before scraping.
Treat HEAD identically to GET in isMetricsRequest; Node drops the body
automatically on HEAD responses.

Refs: #851 (Phase 4 review n3)

* style: replace 'honoured' with 'honored' for spelling consistency (n2)

Aligns the new metrics docstring/log with American spelling used
elsewhere in the README and source.

Refs: #851 (Phase 4 review n2)

* docs(readme): fix metrics sample output to match actual exposition (m4)

The previous sample showed simulator_charging_stations_configured_total
with a 'template' label, but that metric is global with no labels.
The per-template counter is the separate simulator_template_configured
gauge. Replace the sample with the actual exposition shape.

Refs: #851 (Phase 4 review m4)

* test(ui-server): add wsState=undefined and EVSE-mode coverage (M4)

The new tests close two regression-detection gaps identified in Phase 4
review: (a) removing the !== undefined guard on data.wsState would
silently emit NaN — now caught by an absence assertion; (b) the OCPP
2.0.x code path (evses populated, connectors empty, evseStatus.connectors
Map iteration) was entirely untested — now exercised end-to-end.

Refs: #851 (Phase 4 review M4)

* test(ui-server): add soft-cap boundary tests for off-by-one detection (m3)

Probe-then-verify pattern: first scrape with a very high cap to count
actual samples produced; then assert no warn fires when cap equals the
sample count (strict-greater-than semantics) and one warn fires when
cap is one below. This regression test would fail if '>' became '>='
on the soft-cap comparison.

Refs: #851 (Phase 4 review m3)

* test(ui-server): retarget rate-limit burst at allowed loopback path (m1)

T9 previously hit a non-loopback denied path, exercising the rate
limiter on a 403 response rather than on /metrics. Switch to a
loopback request that is allowed through the access policy and assert
that the rate limit fires on /metrics directly.

Refs: #851 (Phase 4 review m1)

* style(test): rename tests per TEST_STYLE_GUIDE and drop redundant assertion (n1, n4)

Renamed all tests from 'T<N>: <verb>...' to 'should <verb>...' format
per tests/TEST_STYLE_GUIDE.md. Also dropped the redundant pre-stop
assertion in 'should clear registry on stop()' (the post-stop
assertion already proves the stop() effect).

Refs: #851 (Phase 4 review n1, n4)

* refactor(ui-server): add HEAD to HttpMethod enum (n1)

Avoid the bare 'HEAD' string literal in UIHttpServer.isMetricsRequest by
extending HttpMethod with an explicit HEAD member, matching the AGENTS.md
guidance to prefer enumerations over string literals when one exists.

* refactor(ui-server): adopt prom-client canonical collect pattern (M1+m1+m2+m3+n2+n3+n4+n5+n6)

Phase 6 review feedback consolidated into one coherent surgery on the
metrics path:

- M1: defineGauge now returns Gauge<L> with auto-injected registers and a
  string-literal label-name generic. Every collect() callback is non-arrow
  with typed 'this: Gauge<L>', per the prom-client documentation. Eliminates
  ~20 unsafe 'as Gauge | undefined' casts and ~30 dead null-guards.
- m1: collapse the four global aggregate gauges into a single tuple-loop,
  mirroring the per-template loop pattern.
- m2: append a terminal '.catch(() => undefined)' to the metricsScrapeChain
  reassignment in stop() so the field always points to a handled promise.
- m3: document the metricsSampleCount/metricsScrapeChain concurrency
  invariants; explicitly forbid async collect callbacks.
- n2: factor 'ChargingStationDataProvider' type alias at module scope.
- n3: drop the redundant outer 'async' on handleMetricsRequest.
- n4: replace box-drawing dividers with plain JSDoc section markers.
- n5: rename 'PII whitelist invariant' to 'PII allowlist invariant'.
- n6: document the OCPP either-or rule on iterateConnectors.

The HTTP /metrics contract is unchanged; existing tests pass without
modification.

* docs(readme): align metrics documentation with code (m4, m5)

- m4: replace the two stale '# HELP' lines in the sample exposition block
  with the strings actually emitted by the registry (commit 1bac5c23d
  fixed two of the four; the other two had drifted again).
- m5: add the 'metrics' sub-key to the uiServer row of the configuration
  table: bullet under the Description column documenting metrics.enabled
  and metrics.softSampleCap, and corresponding shape in the Value type
  column. AGENTS.md 'Documentation conventions / Exhaustivity' treats the
  table as the authoritative tunable list.

* test(ui-server): regression for concurrent /metrics scrapes (R1, R2)

Locks the metricsScrapeChain serialization invariant against future drift:
- R1: two concurrent scrapes against a registry whose sample count equals
  the configured softSampleCap; honest serialized execution produces zero
  warns. A regression that removes the chain (or makes any collect()
  callback async) would either spuriously warn (counter shared between
  scrapes) or corrupt the body, both detected by this test.
- R2: same test indirectly guards against async collect callbacks; the
  invariant is also documented as a JSDoc on buildMetricsRegistry.

* refactor(ui-server): apply Phase 7 polish (M1+n2+n3+n4+n5+n6+n8+n9)

- M1: collapse 3 inline per-station gauges (ws_state, connectors_total,
  boot_status_info) into existing helpers (~40 LOC less duplication).
- n2: simulator_station_connectors_total now counts via iterateConnectors,
  making the iterateConnectors JSDoc 'shared with' claim literally true.
- n3: replace 'const self = this' with a typed 'provider:
  IChargingStationDataProvider' built from .bind(this) method captures.
  Drops the @typescript-eslint/no-this-alias suppression and narrows the
  type surface accessed by collect() callbacks.
- n4: justify defineGauge<L extends string = never> default in the JSDoc
  (stricter than prom-client's 'Gauge<T extends string = string>').
- n5: addPerStationInfoLabel hard-codes 'status' (all callers used it);
  addConnectorOneHot narrows labelName to a literal union of valid keys —
  both eliminate the theoretical 'hash_id'/'connector_id' clobber risk.
- n6: rename ChargingStationDataProvider to IChargingStationDataProvider
  (interface form, repo I-prefix convention) and extend with
  getChargingStationsCount.
- n8: tighten handleMetricsRequest and iterateConnectors @param
  descriptions to remove low-signal restatements.
- n9: drop the explanatory '// Explicit return required by
  promise/always-return lint rule' comment; the lint rule speaks for
  itself when the line is removed.

* test(ui-server): tighten concurrent-scrape regression with sample-count assertion (n1)

Replace the weak 'bodyA === bodyB' check (which would pass even if the
metricsScrapeChain serialization were removed, since both scrapes
collect against the same Registry instance) with an exact sample-line
count assertion against the probed value. Locks two distinct invariants
that bodyA===bodyB did not: no truncation, and no double-count from
interleaved 'collect()' calls under prom-client's internal Promise.all.

* docs(readme): add trailing semicolon to metrics value type (n7)

Align the new 'metrics?: { ... }' member of the uiServer Value type
column with the surrounding style (every other nested-object member
in the same type literal terminates with ';').

* [autofix.ci] apply automated fixes

* refactor(ui-server): apply Phase 8 NITs (n2+n3+n4+n5+n7; n1 deferred)

- n2: rename addPerStationInfoLabel to addPerStationStatusInfo to match
  the helper's actual responsibility (it hard-codes the 'status' label
  per Phase 7 n5).
- n3: drop the I-prefix on ChargingStationDataProvider — most interfaces
  in src/ are unprefixed, only IBootstrap uses I.
- n4: extend the ChargingStationDataProvider JSDoc to acknowledge that
  the inline simulator_ui_server_known_stations_total gauge consumes the
  getChargingStationsCount method (not only the helpers).
- n5: extract countConnectors as a single source of truth for the OCPP
  either-or rule and have simulator_station_connectors_total consume it.
  Also restructure the iterateConnectors JSDoc to cross-reference
  countConnectors instead of the gauge.
- n7: tighten @param res on handleMetricsRequest to 'HTTP response to
  end with the exposition body.' (restores the direction Phase 7 n8
  shortened away).
- n1 (addConnectorOneHot generic threading) is deferred: TypeScript
  cannot narrow the dynamic computed property '[labelName]: v' to
  'Record<L, string>' without an 'as' cast, which AGENTS.md 'Type
  safety' forbids. The runtime literal-union on labelName is kept as
  the type-safety contract; a 3-line comment documents the trade-off.

* refactor(ui-server): apply Phase 9 NITs (n1+n2+n3)

- n1: thread Gauge<...> end-to-end on addConnectorOneHot via a
  ConnectorOneHotLabel type + typed-init + property mutation pattern.
  Phase 9 oracle B proved the cast-free pattern compiles cleanly under
  strict TS 6.0.3 (probe with 8 alternatives, only this one passes).
  Replaces the Phase 8 deferral comment, which mis-attributed an 'as'
  ban to AGENTS.md (AGENTS.md only forbids '!' and 'any').
- n2: drop the self-{@link} on countConnectors JSDoc opening — the
  block documents countConnectors itself, so the cross-reference back
  to it reads awkwardly. Asymmetric pattern with iterateConnectors
  preserved.
- n3: drop the duplicate '(see {@link countConnectors} for the
  invariant source)' parenthetical on iterateConnectors JSDoc — the
  leading 'same...rule as {@link countConnectors}' already directs the
  reader, and 'invariant source' was jargon.

* docs(ui-server): harmonize JSDoc prose with repo cadence

Three Phase 9-pass-2 audit findings against the rest of the codebase:

- Drop the coined phrases 'either-or rule' and 'OCPP-version-driven'
  from countConnectors and iterateConnectors JSDoc; the repo-wide
  prose simply names 'OCPP 1.6' / 'OCPP 2.0.x' inline (see ChargingStation.ts
  '$OCPP 2.0.1 §4.2.3', Helpers.ts 'OCPP 2.0 chargingSchedule',
  TemplateSchema.ts 'OCPP 2.0.1 §7.2'). Use 'mutually exclusive' for the
  source-split semantic.
- Drop the bold-stress '**Invariant**:' prose markers from the
  metricsSampleCount, stop() and buildMetricsRegistry JSDoc; `grep -rn '\*\*'
  src/` returns 0 such markers in JSDoc anywhere else. Inline the
  invariant prose into the surrounding sentences without a section
  marker.

* docs(test): harmonize 'soft cap' spelling and @description cadence

Two outliers found in the test file's prose during repo-wide audit:
- 'soft-cap' (3 occurrences: @description, 1 test name, 1 inline comment)
  was hyphenated while the production warning string and ConfigurationSchema
  JSDoc both spell it 'soft cap' / 'soft sample cap' (no hyphen). Tests'
  string-includes('soft cap') matches were already correct; only the prose
  drifted.
- @description was multi-line whereas every other test file in tests/
  uses a single-line @description (verified across ChargingStation*.test.ts,
  ConfigurationKeyUtils.test.ts, TemplateValidation.test.ts, etc.).

* docs(ui-server): harmonize misconfiguration warning style with repo cadence

The warnIfMisconfigured warning used an em-dash + uppercase 'NOT' to
separate the condition from the consequence:

  metrics.enabled=true is only honored when uiServer.type='http';
  current type='X' — the /metrics endpoint will NOT be served.

Sibling warnings in AbstractUIServer (host-not-allowed and tls-required
at lines 441-457) use semicolon-and-period separators with normal-case
prose and a final action sentence. Match that cadence:

  metrics.enabled=true is honored only when uiServer.type='http'; current
  type='X'. The /metrics endpoint will not be served. Set uiServer.type='http'
  to expose metrics.

Test substring matches ('metrics' / 'http') are preserved.

* test(ui-server): rename M-prefix fixtures to T-prefix convention

The fixture station hash IDs 'station-M{evse,boundary,concurrent}' inherited
the 'M' prefix from the Phase 4 review's MAJOR finding IDs (M1..M4) and
were tokenized by cspell as unknown words 'Mevse', 'Mboundary',
'Mconcurrent' — emitting 7 lint warnings.

The rest of the metrics test file already follows the 'station-T<n>'
numeric convention ('station-T5', 'station-T12', 'station-T13',
'station-T16'), where <n> matches the test ordinal. Map the M-prefixed
fixtures to their actual test ordinals:
- 'Mevse'        -> 'T18' (EVSE-mode test, the 18th 'await it()')
- 'Mboundary-*'  -> 'T19-*' (soft-cap boundary, 19th)
- 'Mconcurrent-*'-> 'T20-*' (concurrent-scrape regression, 20th)

Quality gates now report 0 errors AND 0 warnings; previous baseline
was 0 errors / 7 warnings.

* docs(readme): drop redundant Metrics endpoint section, harmonize uiServer.metrics bullet

The dedicated '### Metrics endpoint (Prometheus)' section duplicated
information that is already authoritative elsewhere:
- The /metrics behavior, configuration shape and defaults are documented
  in the uiServer row of the configuration table (single source of
  truth, per AGENTS.md 'No duplication' rule).
- The PII reject-list and softSampleCap semantics are documented in
  UIServerMetricsConfigurationSchema JSDoc.
- The HTTP-only constraint and the warning behavior are documented in
  the AbstractUIServer.warnIfMisconfigured warning string itself.

Drop the section, the TOC entry, and the now-dangling cross-link from
the table bullet. Restructure the _metrics_ bullet to mirror the
_accessPolicy_ cadence in the same row (top-level intro + indented
sub-bullets per sub-key), so the table description is self-sufficient
and harmonized with its sibling.

* chore(gitignore): ignore .codegraph alongside .omo/

.codegraph is a symlink to .omo/codegraph/projects/<hash>/ used by the
oh-my-opencode tooling, on the same lifecycle as .omo/ and .sisyphus/.
Group it under the existing 'oh-my-opencode' section.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
.gitignore
README.md
package.json
pnpm-lock.yaml
src/charging-station/ui-server/AbstractUIServer.ts
src/charging-station/ui-server/UIHttpServer.ts
src/charging-station/ui-server/UIServerUtils.ts
src/types/ConfigurationData.ts
src/utils/ConfigurationSchema.ts
src/utils/index.ts
tests/charging-station/ui-server/UIMetricsEndpoint.test.ts [new file with mode: 0644]

index 93e554934a547258d9d32c8547bdcc7119b8a958..a64df1c8624fe866543db5a83473d1685a0733fd 100644 (file)
@@ -101,6 +101,7 @@ build/config.gypi
 Thumbs.db
 
 # oh-my-opencode
+.codegraph
 .omo/
 .sisyphus/
 
index 495532a478bf4813c9e4227642101c55fe67465f..fa2c5dd73fe03f806e5207ba495c82269fef2730 100644 (file)
--- a/README.md
+++ b/README.md
@@ -173,17 +173,17 @@ But the modifications to test have to be done to the files in the build target d
 
 **src/assets/config.json**:
 
-| Key                        | Value(s)                                     | Default Value                                                                                                                                                                                                                                                                                                                        | Value type                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
-| -------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| $schemaVersion             | 1                                            | 1                                                                                                                                                                                                                                                                                                                                    | integer                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | Configuration schema version. Set to 1. Files without this field are migrated from v0; deprecated keys are remapped on every load.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
-| supervisionUrls            |                                              | []                                                                                                                                                                                                                                                                                                                                   | string \| string[]                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | string or strings array containing global connection URIs to OCPP-J servers                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
-| supervisionUrlDistribution | round-robin/random/charging-station-affinity | charging-station-affinity                                                                                                                                                                                                                                                                                                            | string                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | supervision urls distribution policy to simulated charging stations                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
-| log                        |                                              | {<br />"enabled": true,<br />"file": "logs/combined.log",<br />"errorFile": "logs/error.log",<br />"statisticsInterval": 60,<br />"level": "info",<br />"console": false,<br />"format": "simple",<br />"rotate": true<br />}                                                                                                        | {<br />enabled?: boolean;<br />file?: string;<br />errorFile?: string;<br />statisticsInterval?: number;<br />level?: string;<br />console?: boolean;<br />format?: string;<br />rotate?: boolean;<br />maxFiles?: string \| number;<br />maxSize?: string \| number;<br />}                                                                                                                                                                                                            | Log configuration section:<br />- _enabled_: enable logging<br />- _file_: log file relative path<br />- _errorFile_: error log file relative path<br />- _statisticsInterval_: seconds between charging stations statistics output in the logs<br />- _level_: emerg/alert/crit/error/warning/notice/info/debug [winston](https://github.com/winstonjs/winston) logging level</br >- _console_: output logs on the console<br />- _format_: [winston](https://github.com/winstonjs/winston) log format<br />- _rotate_: enable daily log files rotation<br />- _maxFiles_: maximum number of log files: https://github.com/winstonjs/winston-daily-rotate-file#options<br />- _maxSize_: maximum size of log files in bytes, or units of kb, mb, and gb: https://github.com/winstonjs/winston-daily-rotate-file#options                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
-| worker                     |                                              | {<br />"processType": "workerSet",<br />"startDelay": 500,<br />"elementAddDelay": 0,<br />"elementsPerWorker": 'auto',<br />"poolMinSize": 4,<br />"poolMaxSize": 16<br />}                                                                                                                                                         | {<br />processType?: WorkerProcessType;<br />startDelay?: number;<br />elementAddDelay?: number;<br />elementsPerWorker?: number \| 'auto' \| 'all';<br />poolMinSize?: number;<br />poolMaxSize?: number;<br />resourceLimits?: ResourceLimits;<br />}                                                                                                                                                                                                                                 | Worker configuration section:<br />- _processType_: worker threads process type (`workerSet`/`fixedPool`/`dynamicPool`)<br />- _startDelay_: milliseconds to wait at worker threads startup (only for `workerSet` worker threads process type)<br />- _elementAddDelay_: milliseconds to wait between charging station add<br />- _elementsPerWorker_: number of charging stations per worker threads for the `workerSet` process type (`auto` means (number of stations) / (number of CPUs) \* 1.5 if (number of stations) > (number of CPUs), otherwise 1; `all` means a unique worker will run all charging stations)<br />- _poolMinSize_: worker threads pool minimum number of threads</br >- _poolMaxSize_: worker threads pool maximum number of threads<br />- _resourceLimits_: worker threads [resource limits](https://nodejs.org/api/worker_threads.html#new-workerfilename-options) object option                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
-| uiServer                   |                                              | {<br />"enabled": false,<br />"type": "ws",<br />"version": "1.1",<br />"accessPolicy": {<br />"requireTlsForNonLoopback": true,<br />"trustedProxies": [],<br />"allowLoopbackProxy": false,<br />"allowedHosts": [],<br />"allowedOrigins": []<br />},<br />"options": {<br />"host": "localhost",<br />"port": 8080<br />}<br />} | {<br />enabled?: boolean;<br />type?: ApplicationProtocol;<br />version?: ApplicationProtocolVersion;<br />accessPolicy?: {<br />requireTlsForNonLoopback?: boolean;<br />trustedProxies?: string[];<br />allowLoopbackProxy?: boolean;<br />allowedHosts?: string[];<br />allowedOrigins?: string[];<br />};<br />options?: ServerOptions;<br />authentication?: {<br />enabled: boolean;<br />type: AuthenticationType;<br />username?: string;<br />password?: string;<br />}<br />} | UI server configuration section:<br />- _enabled_: enable UI server<br />- _type_: 'ws', 'mcp' or 'http' (deprecated)<br />- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')<br />- _accessPolicy_: gateway access policy. Loopback request sources are allowed in plaintext; non-loopback sources require TLS termination by a reverse proxy:<br />&nbsp;&nbsp;- _requireTlsForNonLoopback_: reject non-loopback plaintext requests; the check honors `X-Forwarded-Proto` or `Forwarded: proto=` from a trusted proxy, non-loopback requests without forwarded protocol headers are denied as `tls-required`<br />&nbsp;&nbsp;- _trustedProxies_: IPv4 or IPv6 literals of the immediate reverse proxies whose forwarded headers are honored (hostnames and CIDR ranges are not accepted; only single-hop forwarded chains are honored); a compromised entry can bypass per-client rate limiting by varying `X-Forwarded-For`<br />&nbsp;&nbsp;- _allowLoopbackProxy_: accept forwarded headers when the immediate peer is loopback AND listed in _trustedProxies_ (e.g. `['127.0.0.1', '::1']`)<br />&nbsp;&nbsp;- _allowedHosts_: explicit Host header allowlist; mitigates DNS rebinding when the UI server is exposed through a browser-facing host<br />&nbsp;&nbsp;- _allowedOrigins_: explicit Origin header allowlist; when empty, the request Origin's URL hostname falls back to matching against _allowedHosts_<br />- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)<br />- _authentication_: authentication type configuration section |
-| performanceStorage         |                                              | {<br />"enabled": true,<br />"type": "none",<br />}                                                                                                                                                                                                                                                                                  | {<br />enabled?: boolean;<br />type?: string;<br />uri?: string;<br />}                                                                                                                                                                                                                                                                                                                                                                                                                 | Performance storage configuration section:<br />- _enabled_: enable performance storage<br />- _type_: 'jsonfile', 'mongodb' or 'none'<br />- _uri_: storage URI                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
-| stationTemplateUrls        |                                              | {}[]                                                                                                                                                                                                                                                                                                                                 | {<br />file: string;<br />numberOfStations: number;<br />provisionedNumberOfStations?: number;<br />}[]                                                                                                                                                                                                                                                                                                                                                                                 | array of charging station templates URIs configuration section:<br />- _file_: charging station configuration template file relative path<br />- _numberOfStations_: template number of stations at startup<br />- _provisionedNumberOfStations_: template provisioned number of stations after startup                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
-| persistState               | true/false                                   | true                                                                                                                                                                                                                                                                                                                                 | boolean                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | persist the simulator stopped state to `dist/assets/configurations/.simulator-state.json`. On the next process startup, if the simulator was stopped via the UI procedure `stopSimulator`, charging stations are not auto-spawned and the user can recover via the `startSimulator` procedure. Signal-driven shutdowns (SIGINT/SIGTERM/SIGQUIT) and configuration-reload restarts do not modify the persisted state. The feature requires `uiServer.enabled` to be `true`; otherwise it is silently ignored (no recovery channel without UI). Set the environment variable `SIMULATOR_COLD_START=true` for one run to ignore the persisted state and force a cold start. UI-added charging stations beyond `numberOfStations` are not auto-respawned                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
+| Key                        | Value(s)                                     | Default Value                                                                                                                                                                                                                                                                                                                        | Value type                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
+| -------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| $schemaVersion             | 1                                            | 1                                                                                                                                                                                                                                                                                                                                    | integer                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                | Configuration schema version. Set to 1. Files without this field are migrated from v0; deprecated keys are remapped on every load.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
+| supervisionUrls            |                                              | []                                                                                                                                                                                                                                                                                                                                   | string \| string[]                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | string or strings array containing global connection URIs to OCPP-J servers                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
+| supervisionUrlDistribution | round-robin/random/charging-station-affinity | charging-station-affinity                                                                                                                                                                                                                                                                                                            | string                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | supervision urls distribution policy to simulated charging stations                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
+| log                        |                                              | {<br />"enabled": true,<br />"file": "logs/combined.log",<br />"errorFile": "logs/error.log",<br />"statisticsInterval": 60,<br />"level": "info",<br />"console": false,<br />"format": "simple",<br />"rotate": true<br />}                                                                                                        | {<br />enabled?: boolean;<br />file?: string;<br />errorFile?: string;<br />statisticsInterval?: number;<br />level?: string;<br />console?: boolean;<br />format?: string;<br />rotate?: boolean;<br />maxFiles?: string \| number;<br />maxSize?: string \| number;<br />}                                                                                                                                                                                                                                                                                           | Log configuration section:<br />- _enabled_: enable logging<br />- _file_: log file relative path<br />- _errorFile_: error log file relative path<br />- _statisticsInterval_: seconds between charging stations statistics output in the logs<br />- _level_: emerg/alert/crit/error/warning/notice/info/debug [winston](https://github.com/winstonjs/winston) logging level</br >- _console_: output logs on the console<br />- _format_: [winston](https://github.com/winstonjs/winston) log format<br />- _rotate_: enable daily log files rotation<br />- _maxFiles_: maximum number of log files: https://github.com/winstonjs/winston-daily-rotate-file#options<br />- _maxSize_: maximum size of log files in bytes, or units of kb, mb, and gb: https://github.com/winstonjs/winston-daily-rotate-file#options                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
+| worker                     |                                              | {<br />"processType": "workerSet",<br />"startDelay": 500,<br />"elementAddDelay": 0,<br />"elementsPerWorker": 'auto',<br />"poolMinSize": 4,<br />"poolMaxSize": 16<br />}                                                                                                                                                         | {<br />processType?: WorkerProcessType;<br />startDelay?: number;<br />elementAddDelay?: number;<br />elementsPerWorker?: number \| 'auto' \| 'all';<br />poolMinSize?: number;<br />poolMaxSize?: number;<br />resourceLimits?: ResourceLimits;<br />}                                                                                                                                                                                                                                                                                                                | Worker configuration section:<br />- _processType_: worker threads process type (`workerSet`/`fixedPool`/`dynamicPool`)<br />- _startDelay_: milliseconds to wait at worker threads startup (only for `workerSet` worker threads process type)<br />- _elementAddDelay_: milliseconds to wait between charging station add<br />- _elementsPerWorker_: number of charging stations per worker threads for the `workerSet` process type (`auto` means (number of stations) / (number of CPUs) \* 1.5 if (number of stations) > (number of CPUs), otherwise 1; `all` means a unique worker will run all charging stations)<br />- _poolMinSize_: worker threads pool minimum number of threads</br >- _poolMaxSize_: worker threads pool maximum number of threads<br />- _resourceLimits_: worker threads [resource limits](https://nodejs.org/api/worker_threads.html#new-workerfilename-options) object option                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
+| uiServer                   |                                              | {<br />"enabled": false,<br />"type": "ws",<br />"version": "1.1",<br />"accessPolicy": {<br />"requireTlsForNonLoopback": true,<br />"trustedProxies": [],<br />"allowLoopbackProxy": false,<br />"allowedHosts": [],<br />"allowedOrigins": []<br />},<br />"options": {<br />"host": "localhost",<br />"port": 8080<br />}<br />} | {<br />enabled?: boolean;<br />type?: ApplicationProtocol;<br />version?: ApplicationProtocolVersion;<br />accessPolicy?: {<br />requireTlsForNonLoopback?: boolean;<br />trustedProxies?: string[];<br />allowLoopbackProxy?: boolean;<br />allowedHosts?: string[];<br />allowedOrigins?: string[];<br />};<br />options?: ServerOptions;<br />authentication?: {<br />enabled: boolean;<br />type: AuthenticationType;<br />username?: string;<br />password?: string;<br />};<br />metrics?: {<br />enabled?: boolean;<br />softSampleCap?: number;<br />};<br />} | UI server configuration section:<br />- _enabled_: enable UI server<br />- _type_: 'ws', 'mcp' or 'http' (deprecated)<br />- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')<br />- _accessPolicy_: gateway access policy. Loopback request sources are allowed in plaintext; non-loopback sources require TLS termination by a reverse proxy:<br />&nbsp;&nbsp;- _requireTlsForNonLoopback_: reject non-loopback plaintext requests; the check honors `X-Forwarded-Proto` or `Forwarded: proto=` from a trusted proxy, non-loopback requests without forwarded protocol headers are denied as `tls-required`<br />&nbsp;&nbsp;- _trustedProxies_: IPv4 or IPv6 literals of the immediate reverse proxies whose forwarded headers are honored (hostnames and CIDR ranges are not accepted; only single-hop forwarded chains are honored); a compromised entry can bypass per-client rate limiting by varying `X-Forwarded-For`<br />&nbsp;&nbsp;- _allowLoopbackProxy_: accept forwarded headers when the immediate peer is loopback AND listed in _trustedProxies_ (e.g. `['127.0.0.1', '::1']`)<br />&nbsp;&nbsp;- _allowedHosts_: explicit Host header allowlist; mitigates DNS rebinding when the UI server is exposed through a browser-facing host<br />&nbsp;&nbsp;- _allowedOrigins_: explicit Origin header allowlist; when empty, the request Origin's URL hostname falls back to matching against _allowedHosts_<br />- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)<br />- _authentication_: authentication type configuration section<br />- _metrics_: opt-in Prometheus `/metrics` endpoint (HTTP transport only):<br />&nbsp;&nbsp;- _enabled_: enable the `/metrics` endpoint<br />&nbsp;&nbsp;- _softSampleCap_: soft cardinality cap above which a single warn is logged per scrape (default 5000) |
+| performanceStorage         |                                              | {<br />"enabled": true,<br />"type": "none",<br />}                                                                                                                                                                                                                                                                                  | {<br />enabled?: boolean;<br />type?: string;<br />uri?: string;<br />}                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                | Performance storage configuration section:<br />- _enabled_: enable performance storage<br />- _type_: 'jsonfile', 'mongodb' or 'none'<br />- _uri_: storage URI                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
+| stationTemplateUrls        |                                              | {}[]                                                                                                                                                                                                                                                                                                                                 | {<br />file: string;<br />numberOfStations: number;<br />provisionedNumberOfStations?: number;<br />}[]                                                                                                                                                                                                                                                                                                                                                                                                                                                                | array of charging station templates URIs configuration section:<br />- _file_: charging station configuration template file relative path<br />- _numberOfStations_: template number of stations at startup<br />- _provisionedNumberOfStations_: template provisioned number of stations after startup                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
+| persistState               | true/false                                   | true                                                                                                                                                                                                                                                                                                                                 | boolean                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                | persist the simulator stopped state to `dist/assets/configurations/.simulator-state.json`. On the next process startup, if the simulator was stopped via the UI procedure `stopSimulator`, charging stations are not auto-spawned and the user can recover via the `startSimulator` procedure. Signal-driven shutdowns (SIGINT/SIGTERM/SIGQUIT) and configuration-reload restarts do not modify the persisted state. The feature requires `uiServer.enabled` to be `true`; otherwise it is silently ignored (no recovery channel without UI). Set the environment variable `SIMULATOR_COLD_START=true` for one run to ignore the persisted state and force a cold start. UI-added charging stations beyond `numberOfStations` are not auto-respawned                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
 
 #### Worker process model
 
index 4c99a0250a761839b319313e81e7d2e806530db5..78cb157faf8814f5372cd0afbf23aa300d32b475 100644 (file)
@@ -88,6 +88,7 @@
     "mnemonist": "0.40.4",
     "mongodb": "^7.3.0",
     "poolifier": "^5.3.2",
+    "prom-client": "^15.1.3",
     "tar": "^7.5.16",
     "winston": "^3.19.0",
     "winston-daily-rotate-file": "^5.0.0",
index 431ad05470088cbde494a7cc33ee304ab442dc26..8e397f5209aaebbe872c93306efe06dcc271756f 100644 (file)
@@ -53,6 +53,9 @@ importers:
       poolifier:
         specifier: ^5.3.2
         version: 5.3.2
+      prom-client:
+        specifier: ^15.1.3
+        version: 15.1.3
       tar:
         specifier: ^7.5.16
         version: 7.5.16
@@ -248,7 +251,7 @@ importers:
         version: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.9.0)
       vitest:
         specifier: ^4.1.9
-        version: 4.1.9(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.9.0))
+        version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.9.0))
       vue-tsc:
         specifier: ^3.3.5
         version: 3.3.5(typescript@6.0.3)
@@ -1179,6 +1182,10 @@ packages:
   '@one-ini/wasm@0.1.1':
     resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
 
+  '@opentelemetry/api@1.9.1':
+    resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
+    engines: {node: '>=8.0.0'}
+
   '@oxc-project/types@0.133.0':
     resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
 
@@ -1869,6 +1876,9 @@ packages:
   bindings@1.5.0:
     resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
 
+  bintrees@1.0.2:
+    resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
+
   birpc@2.9.0:
     resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
 
@@ -3926,6 +3936,10 @@ packages:
     engines: {node: '>=14'}
     hasBin: true
 
+  prom-client@15.1.3:
+    resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==}
+    engines: {node: ^16 || ^18 || >=20}
+
   prop-types@15.8.1:
     resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
 
@@ -4346,6 +4360,9 @@ packages:
     resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==}
     engines: {node: '>=8.0.0'}
 
+  tdigest@0.1.2:
+    resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
+
   text-hex@1.0.0:
     resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==}
 
@@ -5870,6 +5887,8 @@ snapshots:
 
   '@one-ini/wasm@0.1.1': {}
 
+  '@opentelemetry/api@1.9.1': {}
+
   '@oxc-project/types@0.133.0': {}
 
   '@parcel/watcher-android-arm64@2.5.6':
@@ -6237,7 +6256,7 @@ snapshots:
       obug: 2.1.3
       std-env: 4.1.0
       tinyrainbow: 3.1.0
-      vitest: 4.1.9(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.9.0))
+      vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.9.0))
 
   '@vitest/expect@4.1.9':
     dependencies:
@@ -6612,6 +6631,8 @@ snapshots:
     dependencies:
       file-uri-to-path: 1.0.0
 
+  bintrees@1.0.2: {}
+
   birpc@2.9.0: {}
 
   bl@4.1.0:
@@ -8813,6 +8834,11 @@ snapshots:
 
   prettier@3.8.4: {}
 
+  prom-client@15.1.3:
+    dependencies:
+      '@opentelemetry/api': 1.9.1
+      tdigest: 0.1.2
+
   prop-types@15.8.1:
     dependencies:
       loose-envify: 1.4.0
@@ -9337,6 +9363,10 @@ snapshots:
 
   tarn@3.0.2: {}
 
+  tdigest@0.1.2:
+    dependencies:
+      bintrees: 1.0.2
+
   text-hex@1.0.0: {}
 
   tildify@2.0.0: {}
@@ -9570,7 +9600,7 @@ snapshots:
       tsx: 4.22.4
       yaml: 2.9.0
 
-  vitest@4.1.9(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.9.0)):
+  vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.9.0)):
     dependencies:
       '@vitest/expect': 4.1.9
       '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.9.0))
@@ -9593,6 +9623,7 @@ snapshots:
       vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.9.0)
       why-is-node-running: 2.3.0
     optionalDependencies:
+      '@opentelemetry/api': 1.9.1
       '@types/node': 24.13.2
       '@vitest/coverage-v8': 4.1.9(vitest@4.1.9)
       jsdom: 29.1.1
index 6b97e92eb51598d24459757e9599ae63768999f1..a86e03409dd8c94a1d7b071be935207a4ee3212f 100644 (file)
@@ -9,6 +9,7 @@ import type { AbstractUIService } from './ui-services/AbstractUIService.js'
 
 import { BaseError } from '../../exception/index.js'
 import {
+  ApplicationProtocol,
   ApplicationProtocolVersion,
   AuthenticationType,
   type ChargingStationData,
@@ -422,6 +423,20 @@ export abstract class AbstractUIServer {
     const isWildcard =
       configuredHost === '' || configuredHost === '0.0.0.0' || configuredHost === '::'
 
+    if (
+      this.uiServerConfiguration.metrics?.enabled === true &&
+      this.uiServerConfiguration.type !== ApplicationProtocol.HTTP
+    ) {
+      logger.warn(
+        `${this.logPrefix(
+          moduleName,
+          'constructor'
+        )} metrics.enabled=true is honored only when uiServer.type='http'; current type='${
+          this.uiServerConfiguration.type ?? 'undefined'
+        }'. The /metrics endpoint will not be served. Set uiServer.type='http' to expose metrics.`
+      )
+    }
+
     if (isWildcard && allowedHosts.length === 0) {
       logger.warn(
         `${this.logPrefix(
index ec4c1d6ec02c62a7404994c4b0aa28e3ce2ddc1d..c62fc9bedd7aae1ee5ab5ec49c5b59b91c736b7e 100644 (file)
@@ -2,7 +2,9 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
 
 import { getReasonPhrase, StatusCodes } from 'http-status-codes'
 import { createGzip } from 'node:zlib'
+import { Gauge, type GaugeConfiguration, Registry } from 'prom-client'
 
+import type { ChargingStationData, ConnectorEntry, ConnectorStatus } from '../../types/index.js'
 import type { IBootstrap } from '../IBootstrap.js'
 
 import { BaseError } from '../../exception/index.js'
@@ -31,6 +33,30 @@ import { HttpMethod, isProtocolAndVersionSupported } from './UIServerUtils.js'
 
 const moduleName = 'UIHttpServer'
 
+/**
+ * Soft cardinality cap for the Prometheus exposition. When a single scrape
+ * emits more samples than this threshold, a single `logger.warn` is logged
+ * and the response is still served in full. There is no truncation and no
+ * scrape failure; the operator decides whether to disable the endpoint or
+ * scale the threshold.
+ */
+export const METRICS_SOFT_SAMPLE_CAP = 5_000
+
+const METRICS_PATHNAME = '/metrics'
+
+/**
+ * Subset of {@link AbstractUIServer} consumed by the metrics gauge helpers
+ * and by the inline `simulator_ui_server_known_stations_total` gauge in
+ * {@link UIHttpServer.buildMetricsRegistry}. Restricting access to this
+ * projection keeps the metrics surface decoupled from the concrete UI
+ * server class and from `this`-rebound contexts inside `collect()`
+ * callbacks.
+ */
+interface ChargingStationDataProvider {
+  getChargingStationsCount(): number
+  listChargingStationData(): ChargingStationData[]
+}
+
 /**
  * @deprecated Use UIMCPServer (ApplicationProtocol.MCP) instead. Will be removed in a future major version.
  */
@@ -38,6 +64,19 @@ export class UIHttpServer extends AbstractUIServer {
   protected override readonly uiServerType = 'UI HTTP Server'
 
   private readonly acceptsGzip: Map<UUIDv4, boolean>
+  private metricsRegistry?: Registry
+  /**
+   * Per-scrape sample counter. Reset to 0 at the start of each scrape and
+   * read after `Registry.metrics()` resolves. Mutated by the `accountSamples`
+   * closure captured by every `collect()` callback in {@link buildMetricsRegistry}.
+   * Concurrency safety relies on {@link metricsScrapeChain}: every scrape
+   * (`reset → await Registry.metrics() → read`) runs as a single link in a
+   * serial promise chain, so no two scrapes interleave their counter
+   * mutations. Removing the chain or making any `collect()` callback `async`
+   * breaks this guarantee.
+   */
+  private metricsSampleCount = 0
+  private metricsScrapeChain: Promise<void> = Promise.resolve()
 
   public constructor (
     protected override readonly uiServerConfiguration: UIServerConfiguration,
@@ -102,9 +141,526 @@ export class UIHttpServer extends AbstractUIServer {
    */
   public start (): void {
     this.httpServer.on('request', this.requestListener.bind(this))
+    if (
+      this.uiServerConfiguration.metrics?.enabled === true &&
+      this.metricsRegistry === undefined
+    ) {
+      this.metricsRegistry = this.buildMetricsRegistry()
+    }
     this.startHttpServer()
   }
 
+  /**
+   * Stop the HTTP UI server and release any Prometheus registry held by
+   * {@link metricsRegistry}. The registry clear is sequenced AFTER any
+   * in-flight scrape via `metricsScrapeChain.finally`; calling
+   * `registry.clear()` synchronously would race a running `collect()`
+   * callback's `this.reset()`. The terminal `.catch(() => undefined)`
+   * guarantees the chain field always points to a handled promise (no
+   * `UnhandledPromiseRejection` if the last in-flight scrape rejected and
+   * no further scrape is queued).
+   */
+  public override stop (): void {
+    if (this.metricsRegistry !== undefined) {
+      const registry = this.metricsRegistry
+      this.metricsScrapeChain = this.metricsScrapeChain
+        .finally(() => {
+          registry.clear()
+        })
+        .catch(() => undefined)
+      this.metricsRegistry = undefined
+    }
+    super.stop()
+  }
+
+  /**
+   * Build the Prometheus `Registry` populated with every gauge exposed by
+   * the simulator. Each gauge declares an explicit source field via its
+   * `collect()` callback; there is no generic property iteration so adding
+   * a new field on `ChargingStationData` does NOT silently expose it
+   * (PII allowlist invariant). All `collect()` callbacks registered here
+   * are synchronous; an async `collect` would let `prom-client` interleave
+   * them within a single `Registry.metrics()` call, racing on
+   * {@link metricsSampleCount}.
+   * @returns The populated registry; `Registry.metrics()` renders the body.
+   */
+  private buildMetricsRegistry (): Registry {
+    const registry = new Registry()
+    const bootstrap = this.getBootstrap()
+    const provider: ChargingStationDataProvider = {
+      getChargingStationsCount: this.getChargingStationsCount.bind(this),
+      listChargingStationData: this.listChargingStationData.bind(this),
+    }
+    const accountSamples = (n: number): void => {
+      this.metricsSampleCount += n
+    }
+
+    /** Global gauges. */
+
+    defineGauge(registry, {
+      collect (this: Gauge<'version'>) {
+        this.reset()
+        this.labels({ version: bootstrap.getState().version }).set(1)
+        accountSamples(1)
+      },
+      help: 'Simulator process information.',
+      labelNames: ['version'] as const,
+      name: 'simulator_info',
+    })
+
+    defineGauge(registry, {
+      collect (this: Gauge) {
+        this.reset()
+        this.set(bootstrap.getState().started ? 1 : 0)
+        accountSamples(1)
+      },
+      help: '1 when the simulator is started, 0 otherwise.',
+      name: 'simulator_started',
+    })
+
+    defineGauge(registry, {
+      collect (this: Gauge) {
+        this.reset()
+        this.set(bootstrap.getState().templateStatistics.size)
+        accountSamples(1)
+      },
+      help: 'Number of charging station templates configured.',
+      name: 'simulator_charging_station_templates_total',
+    })
+
+    // Aggregate counters across templates. Tuple shape: [metric name, key, help].
+    // HELP text is preserved verbatim per public-API stability (Prometheus
+    // exposition is observable to operators).
+    const stationAggregates = [
+      [
+        'simulator_charging_stations_configured_total',
+        'configured',
+        'Number of charging stations configured across all templates.',
+      ],
+      [
+        'simulator_charging_stations_provisioned_total',
+        'provisioned',
+        'Number of charging stations provisioned across all templates.',
+      ],
+      [
+        'simulator_charging_stations_added_total',
+        'added',
+        'Number of charging stations added in the current process.',
+      ],
+      [
+        'simulator_charging_stations_started_total',
+        'started',
+        'Number of charging stations currently started.',
+      ],
+    ] as const
+    for (const [name, key, help] of stationAggregates) {
+      defineGauge(registry, {
+        collect (this: Gauge) {
+          let total = 0
+          for (const t of bootstrap.getState().templateStatistics.values()) {
+            total += t[key]
+          }
+          this.reset()
+          this.set(total)
+          accountSamples(1)
+        },
+        help,
+        name,
+      })
+    }
+
+    defineGauge(registry, {
+      collect (this: Gauge) {
+        this.reset()
+        this.set(provider.getChargingStationsCount())
+        accountSamples(1)
+      },
+      help: 'Number of charging station snapshots cached on the UI server.',
+      name: 'simulator_ui_server_known_stations_total',
+    })
+
+    for (const [name, key] of [
+      ['simulator_template_added', 'added'],
+      ['simulator_template_configured', 'configured'],
+      ['simulator_template_provisioned', 'provisioned'],
+      ['simulator_template_started', 'started'],
+    ] as const) {
+      defineGauge(registry, {
+        collect (this: Gauge<'template'>) {
+          this.reset()
+          for (const [templateName, t] of bootstrap.getState().templateStatistics) {
+            this.labels({ template: templateName }).set(t[key])
+            accountSamples(1)
+          }
+        },
+        help: `Per-template '${key}' charging stations counter.`,
+        labelNames: ['template'] as const,
+        name,
+      })
+    }
+
+    /** Per charging station gauges. */
+
+    defineGauge(registry, {
+      collect (
+        this: Gauge<
+          'current_out_type' | 'firmware_version' | 'hash_id' | 'model' | 'ocpp_version' | 'vendor'
+        >
+      ) {
+        this.reset()
+        for (const data of provider.listChargingStationData()) {
+          this.labels({
+            current_out_type: stringLabel(data.stationInfo.currentOutType),
+            firmware_version: stringLabel(data.stationInfo.firmwareVersion),
+            hash_id: data.stationInfo.hashId,
+            model: stringLabel(data.stationInfo.chargePointModel),
+            ocpp_version: stringLabel(data.stationInfo.ocppVersion),
+            vendor: stringLabel(data.stationInfo.chargePointVendor),
+          }).set(1)
+          accountSamples(1)
+        }
+      },
+      help: 'Static information for the charging station (vendor / model / firmware / ocpp).',
+      labelNames: [
+        'hash_id',
+        'vendor',
+        'model',
+        'firmware_version',
+        'ocpp_version',
+        'current_out_type',
+      ] as const,
+      name: 'simulator_station_info',
+    })
+
+    addPerStationBoolean(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_station_started',
+      '1 when the charging station is started, 0 otherwise.',
+      data => data.started
+    )
+
+    addPerStationNumeric(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_station_ws_state',
+      'WebSocket readyState (0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED).',
+      data => data.wsState
+    )
+
+    addPerStationNumeric(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_station_connectors_total',
+      'Number of connectors of the charging station.',
+      countConnectors
+    )
+
+    addPerStationNumeric(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_station_evses_total',
+      'Number of EVSEs of the charging station.',
+      data => data.evses.length
+    )
+
+    addPerStationNumeric(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_station_max_power_watts',
+      'Maximum power of the charging station, in Watts.',
+      data => data.stationInfo.maximumPower
+    )
+
+    addPerStationNumeric(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_station_max_amperage_amperes',
+      'Maximum amperage of the charging station, in Amperes.',
+      data => data.stationInfo.maximumAmperage
+    )
+
+    addPerStationNumeric(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_station_voltage_out_volts',
+      'Voltage output of the charging station, in Volts.',
+      data => data.stationInfo.voltageOut
+    )
+
+    addPerStationNumeric(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_station_data_timestamp_seconds',
+      'Unix epoch (seconds) at which the charging station snapshot was emitted.',
+      data => Math.floor(data.timestamp / 1000)
+    )
+
+    addPerStationStatusInfo(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_station_boot_status_info',
+      'BootNotification status (one-hot).',
+      data => data.bootNotificationResponse?.status
+    )
+
+    addPerStationNumeric(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_station_boot_heartbeat_interval_seconds',
+      'BootNotification heartbeat interval, in seconds.',
+      data => data.bootNotificationResponse?.interval
+    )
+
+    addPerStationBoolean(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_station_atg_enabled',
+      '1 when the ATG is enabled in configuration, 0 otherwise.',
+      data => data.automaticTransactionGenerator?.automaticTransactionGenerator?.enable === true
+    )
+
+    addPerStationStatusInfo(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_station_diagnostics_status_info',
+      'Most recent DiagnosticsStatusNotification status (one-hot).',
+      data => data.stationInfo.diagnosticsStatus
+    )
+
+    addPerStationStatusInfo(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_station_firmware_status_info',
+      'Most recent FirmwareStatusNotification status (one-hot).',
+      data => data.stationInfo.firmwareStatus
+    )
+
+    addPerStationNumeric(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_station_ocpp_config_keys_total',
+      'Number of OCPP configuration keys advertised by the charging station.',
+      data => data.ocppConfiguration.configurationKey?.length ?? 0
+    )
+
+    /** Per connector gauges. */
+
+    addConnectorOneHot(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_status_info',
+      'Connector status (one-hot).',
+      'status',
+      cs => cs.status
+    )
+    addConnectorOneHot(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_boot_status_info',
+      'Connector boot status (one-hot).',
+      'status',
+      cs => cs.bootStatus
+    )
+    addConnectorOneHot(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_availability_info',
+      'Connector availability (one-hot).',
+      'availability',
+      cs => cs.availability
+    )
+    addConnectorOneHot(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_error_code_info',
+      'Connector OCPP error code (one-hot).',
+      'error_code',
+      cs => cs.errorCode
+    )
+    addConnectorOneHot(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_type_info',
+      'Connector physical type (one-hot).',
+      'connector_type',
+      cs => cs.type
+    )
+
+    addConnectorBoolean(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_locked',
+      '1 when the connector is locked, 0 otherwise.',
+      cs => cs.locked === true
+    )
+    addConnectorBoolean(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_transaction_started',
+      '1 when a transaction is currently started on the connector.',
+      cs => cs.transactionStarted === true
+    )
+    addConnectorBoolean(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_transaction_pending',
+      '1 when a transaction is pending on the connector.',
+      cs => cs.transactionPending === true
+    )
+    addConnectorBoolean(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_transaction_remote_started',
+      '1 when the current transaction was remote-started.',
+      cs => cs.transactionRemoteStarted === true
+    )
+    addConnectorBoolean(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_reservation_active',
+      '1 when an active reservation is set on the connector.',
+      cs => cs.reservation != null
+    )
+
+    addConnectorNumeric(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_transaction_seq_no',
+      'Last transaction event sequence number sent on the connector.',
+      cs => cs.transactionSeqNo
+    )
+    addConnectorNumeric(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_transaction_event_queue_size',
+      'Number of pending transaction events queued on the connector.',
+      cs => cs.transactionEventQueue?.length ?? 0
+    )
+    addConnectorNumeric(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_transaction_id',
+      'Numeric transaction id of the active transaction on the connector. NEVER used as a label (cardinality).',
+      cs => (typeof cs.transactionId === 'number' ? cs.transactionId : undefined)
+    )
+    addConnectorNumeric(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_transaction_start_seconds',
+      'Unix epoch (seconds) at which the active transaction started on the connector.',
+      cs =>
+        cs.transactionStart != null ? Math.floor(cs.transactionStart.getTime() / 1000) : undefined
+    )
+    addConnectorNumeric(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_transaction_energy_active_import_register_wh',
+      'Active energy imported during the current transaction, in Wh.',
+      cs => cs.transactionEnergyActiveImportRegisterValue
+    )
+    addConnectorNumeric(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_energy_active_import_register_wh',
+      'Cumulative active energy imported by the connector meter, in Wh.',
+      cs => cs.energyActiveImportRegisterValue
+    )
+    addConnectorNumeric(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_max_power_watts',
+      'Maximum power of the connector, in Watts.',
+      cs => cs.maximumPower
+    )
+    addConnectorNumeric(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_charging_profiles_total',
+      'Number of charging profiles installed on the connector.',
+      cs => cs.chargingProfiles?.length ?? 0
+    )
+    addConnectorNumericFromEntry(
+      registry,
+      accountSamples,
+      provider,
+      'simulator_connector_evse_id',
+      'EVSE id the connector belongs to.',
+      entry => entry.evseId
+    )
+
+    return registry
+  }
+
+  /**
+   * Schedule a `/metrics` scrape onto {@link metricsScrapeChain} so concurrent
+   * scrape requests serialize through a single FIFO chain (preserves the
+   * {@link metricsSampleCount} invariant). The function itself is synchronous —
+   * the inner async work runs in a `.then()` continuation and rejections
+   * propagate to the returned promise, which the listener-side `.catch()`
+   * converts to HTTP 500.
+   * @param res HTTP response to end with the exposition body.
+   * @param registry Source Prometheus registry.
+   * @returns The chained scrape promise.
+   */
+  private handleMetricsRequest (res: ServerResponse, registry: Registry): Promise<void> {
+    this.metricsScrapeChain = this.metricsScrapeChain
+      .catch(() => undefined)
+      .then(async () => {
+        this.metricsSampleCount = 0
+        const body = await registry.metrics()
+        const cap = this.uiServerConfiguration.metrics?.softSampleCap ?? METRICS_SOFT_SAMPLE_CAP
+        if (this.metricsSampleCount > cap) {
+          logger.warn(
+            `${this.logPrefix(moduleName, 'handleMetricsRequest')} ` +
+              `Prometheus scrape produced ${this.metricsSampleCount.toString()} samples ` +
+              `(soft cap ${cap.toString()})`
+          )
+        }
+        if (!res.headersSent && !res.writableEnded) {
+          res
+            .writeHead(StatusCodes.OK, {
+              'Content-Type': registry.contentType,
+            })
+            .end(body)
+        }
+        return undefined
+      })
+    return this.metricsScrapeChain
+  }
+
   private async handleRequestBody (
     req: IncomingMessage,
     res: ServerResponse,
@@ -141,6 +697,19 @@ export class UIHttpServer extends AbstractUIServer {
     }
   }
 
+  private isMetricsRequest (req: IncomingMessage): boolean {
+    if (req.method !== HttpMethod.GET && req.method !== HttpMethod.HEAD) {
+      return false
+    }
+    const rawUrl = req.url ?? ''
+    try {
+      const { pathname } = new URL(rawUrl, 'http://localhost')
+      return pathname === METRICS_PATHNAME
+    } catch {
+      return false
+    }
+  }
+
   private requestListener (req: IncomingMessage, res: ServerResponse): void {
     const prologue = this.runRequestPrologue(req)
     if (!prologue.ok) {
@@ -152,6 +721,20 @@ export class UIHttpServer extends AbstractUIServer {
       return
     }
 
+    if (this.metricsRegistry !== undefined && this.isMetricsRequest(req)) {
+      const registry = this.metricsRegistry
+      this.handleMetricsRequest(res, registry).catch((error: unknown) => {
+        logger.error(
+          `${this.logPrefix(moduleName, 'requestListener.metrics')} Metrics handler error:`,
+          error
+        )
+        if (!res.headersSent) {
+          res.writeHead(StatusCodes.INTERNAL_SERVER_ERROR, { 'Content-Type': 'text/plain' }).end()
+        }
+      })
+      return
+    }
+
     const uuid = generateUUID()
     this.responseHandlers.set(uuid, res)
     const acceptEncoding = req.headers['accept-encoding'] ?? ''
@@ -229,3 +812,253 @@ export class UIHttpServer extends AbstractUIServer {
     }
   }
 }
+
+/**
+ * Construct and register a Prometheus `Gauge` whose `labelNames` are narrowed
+ * to a string-literal union via `as const`. The returned reference is owned
+ * by `registry` (lifecycle managed by `Registry.clear()`); callers may ignore
+ * it because each gauge's `collect()` callback receives the gauge as its
+ * `this` binding. The `L = never` default (stricter than `prom-client`'s own
+ * `Gauge<T extends string = string>`) forbids passing `labelNames` for
+ * unlabeled gauges, catching mismatches at compile time.
+ * @param registry Destination registry; auto-injected into `registers`.
+ * @param config Gauge configuration WITHOUT `registers`.
+ * @returns The constructed `Gauge<L>`.
+ */
+const defineGauge = <L extends string = never>(
+  registry: Registry,
+  config: Omit<GaugeConfiguration<L>, 'registers'>
+): Gauge<L> => new Gauge<L>({ ...config, registers: [registry] })
+
+const stringLabel = (value: string | undefined): string => value ?? ''
+
+const addPerStationNumeric = (
+  registry: Registry,
+  account: (n: number) => void,
+  server: ChargingStationDataProvider,
+  name: string,
+  help: string,
+  pick: (data: ChargingStationData) => number | undefined
+): void => {
+  defineGauge(registry, {
+    collect (this: Gauge<'hash_id'>) {
+      this.reset()
+      for (const data of server.listChargingStationData()) {
+        const v = pick(data)
+        if (typeof v === 'number') {
+          this.labels({ hash_id: data.stationInfo.hashId }).set(v)
+          account(1)
+        }
+      }
+    },
+    help,
+    labelNames: ['hash_id'] as const,
+    name,
+  })
+}
+
+const addPerStationBoolean = (
+  registry: Registry,
+  account: (n: number) => void,
+  server: ChargingStationDataProvider,
+  name: string,
+  help: string,
+  pick: (data: ChargingStationData) => boolean
+): void => {
+  defineGauge(registry, {
+    collect (this: Gauge<'hash_id'>) {
+      this.reset()
+      for (const data of server.listChargingStationData()) {
+        this.labels({ hash_id: data.stationInfo.hashId }).set(pick(data) ? 1 : 0)
+        account(1)
+      }
+    },
+    help,
+    labelNames: ['hash_id'] as const,
+    name,
+  })
+}
+
+const addPerStationStatusInfo = (
+  registry: Registry,
+  account: (n: number) => void,
+  server: ChargingStationDataProvider,
+  name: string,
+  help: string,
+  pick: (data: ChargingStationData) => string | undefined
+): void => {
+  defineGauge(registry, {
+    collect (this: Gauge<'hash_id' | 'status'>) {
+      this.reset()
+      for (const data of server.listChargingStationData()) {
+        const v = pick(data)
+        if (typeof v === 'string') {
+          this.labels({ hash_id: data.stationInfo.hashId, status: v }).set(1)
+          account(1)
+        }
+      }
+    },
+    help,
+    labelNames: ['hash_id', 'status'] as const,
+    name,
+  })
+}
+
+/**
+ * Connector count under the OCPP 1.6 (`data.connectors`) vs OCPP 2.0.x
+ * (`data.evses[*].evseStatus.connectors`) source split. The two sources
+ * are mutually exclusive: `buildConnectorEntries` guarantees
+ * `data.connectors` is empty when `data.evses` is populated; never sum
+ * them. Also used by {@link iterateConnectors}.
+ * @param data Charging station snapshot.
+ * @returns Connector count under the active mode.
+ */
+const countConnectors = (data: ChargingStationData): number =>
+  data.connectors.length > 0
+    ? data.connectors.length
+    : data.evses.reduce((n, evse) => n + evse.evseStatus.connectors.size, 0)
+
+/**
+ * Iterate connectors under the same OCPP 1.6 vs OCPP 2.0.x source split as
+ * {@link countConnectors}: yields entries from `data.connectors` when
+ * non-empty, otherwise from `data.evses[*].evseStatus.connectors`. The two
+ * sources are mutually exclusive; never sum them.
+ * @param data Charging station snapshot.
+ * @yields {ConnectorEntry} A connector entry under the active mode.
+ */
+const iterateConnectors = function * (data: ChargingStationData): Generator<ConnectorEntry> {
+  if (data.connectors.length > 0) {
+    for (const entry of data.connectors) {
+      yield entry
+    }
+    return
+  }
+  for (const evse of data.evses) {
+    for (const [connectorId, connectorStatus] of evse.evseStatus.connectors) {
+      yield { connectorId, connectorStatus, evseId: evse.evseId }
+    }
+  }
+}
+
+type ConnectorOneHotLabel = 'availability' | 'connector_type' | 'error_code' | 'status'
+
+const addConnectorOneHot = (
+  registry: Registry,
+  account: (n: number) => void,
+  server: ChargingStationDataProvider,
+  name: string,
+  help: string,
+  labelName: ConnectorOneHotLabel,
+  pick: (cs: ConnectorStatus) => string | undefined
+): void => {
+  defineGauge<'connector_id' | 'hash_id' | ConnectorOneHotLabel>(registry, {
+    collect (this: Gauge<'connector_id' | 'hash_id' | ConnectorOneHotLabel>) {
+      this.reset()
+      for (const data of server.listChargingStationData()) {
+        for (const entry of iterateConnectors(data)) {
+          const v = pick(entry.connectorStatus)
+          if (typeof v === 'string') {
+            const labels: Partial<
+              Record<'connector_id' | 'hash_id' | ConnectorOneHotLabel, string>
+            > = {}
+            labels.hash_id = data.stationInfo.hashId
+            labels.connector_id = entry.connectorId.toString()
+            labels[labelName] = v
+            this.labels(labels).set(1)
+            account(1)
+          }
+        }
+      }
+    },
+    help,
+    labelNames: ['hash_id', 'connector_id', labelName],
+    name,
+  })
+}
+
+const addConnectorBoolean = (
+  registry: Registry,
+  account: (n: number) => void,
+  server: ChargingStationDataProvider,
+  name: string,
+  help: string,
+  pick: (cs: ConnectorStatus) => boolean
+): void => {
+  defineGauge(registry, {
+    collect (this: Gauge<'connector_id' | 'hash_id'>) {
+      this.reset()
+      for (const data of server.listChargingStationData()) {
+        for (const entry of iterateConnectors(data)) {
+          this.labels({
+            connector_id: entry.connectorId.toString(),
+            hash_id: data.stationInfo.hashId,
+          }).set(pick(entry.connectorStatus) ? 1 : 0)
+          account(1)
+        }
+      }
+    },
+    help,
+    labelNames: ['hash_id', 'connector_id'] as const,
+    name,
+  })
+}
+
+const addConnectorNumeric = (
+  registry: Registry,
+  account: (n: number) => void,
+  server: ChargingStationDataProvider,
+  name: string,
+  help: string,
+  pick: (cs: ConnectorStatus) => number | undefined
+): void => {
+  defineGauge(registry, {
+    collect (this: Gauge<'connector_id' | 'hash_id'>) {
+      this.reset()
+      for (const data of server.listChargingStationData()) {
+        for (const entry of iterateConnectors(data)) {
+          const v = pick(entry.connectorStatus)
+          if (typeof v === 'number') {
+            this.labels({
+              connector_id: entry.connectorId.toString(),
+              hash_id: data.stationInfo.hashId,
+            }).set(v)
+            account(1)
+          }
+        }
+      }
+    },
+    help,
+    labelNames: ['hash_id', 'connector_id'] as const,
+    name,
+  })
+}
+
+const addConnectorNumericFromEntry = (
+  registry: Registry,
+  account: (n: number) => void,
+  server: ChargingStationDataProvider,
+  name: string,
+  help: string,
+  pick: (entry: ConnectorEntry) => number | undefined
+): void => {
+  defineGauge(registry, {
+    collect (this: Gauge<'connector_id' | 'hash_id'>) {
+      this.reset()
+      for (const data of server.listChargingStationData()) {
+        for (const entry of iterateConnectors(data)) {
+          const v = pick(entry)
+          if (typeof v === 'number') {
+            this.labels({
+              connector_id: entry.connectorId.toString(),
+              hash_id: data.stationInfo.hashId,
+            }).set(v)
+            account(1)
+          }
+        }
+      }
+    },
+    help,
+    labelNames: ['hash_id', 'connector_id'] as const,
+    name,
+  })
+}
index a2df33d0284f950d9ff3c04cdd1ae903bb9e7ff0..52ec53a144e4673e79d588e3efe543eae3f7fe8a 100644 (file)
@@ -6,6 +6,7 @@ import { isEmpty, logger, logPrefix } from '../../utils/index.js'
 export enum HttpMethod {
   DELETE = 'DELETE',
   GET = 'GET',
+  HEAD = 'HEAD',
   PATCH = 'PATCH',
   POST = 'POST',
   PUT = 'PUT',
index 8e738b818130fa9b87e26b75aa5b35e68e0496f5..81318c4a5f89dee274d5a19e56f7dc725aec09a3 100644 (file)
@@ -6,6 +6,7 @@ import type {
   StationTemplateUrlSchema,
   StorageConfigurationSchema,
   UIServerConfigurationSchema,
+  UIServerMetricsConfigurationSchema,
   WorkerConfigurationSchema,
 } from '../utils/index.js'
 
@@ -33,4 +34,5 @@ export type LogConfiguration = z.infer<typeof LogConfigurationSchema>
 export type StationTemplateUrl = z.infer<typeof StationTemplateUrlSchema>
 export type StorageConfiguration = z.infer<typeof StorageConfigurationSchema>
 export type UIServerConfiguration = z.infer<typeof UIServerConfigurationSchema>
+export type UIServerMetricsConfiguration = z.infer<typeof UIServerMetricsConfigurationSchema>
 export type WorkerConfiguration = z.infer<typeof WorkerConfigurationSchema>
index c3bca037dbea3ca4365fa549725966f52b09bb69..919ca3a427544cbabb7b86795656c946d4699d33 100644 (file)
@@ -212,6 +212,22 @@ const UIServerListenOptionsSchema = z
   })
   .pipe(UIServerListenOptionsObjectSchema)
 
+/**
+ * UIServerMetricsConfiguration — opt-in Prometheus /metrics endpoint
+ * served by `UIHttpServer`. Honored only when the parent UI server is
+ * running on the HTTP transport (Prometheus is HTTP-only by spec).
+ *
+ * `softSampleCap` (optional, default `METRICS_SOFT_SAMPLE_CAP` = 5000)
+ * is the soft cardinality cap above which a single `logger.warn` is
+ * emitted per scrape; the response is still served in full.
+ */
+export const UIServerMetricsConfigurationSchema = z
+  .object({
+    enabled: z.boolean().optional(),
+    softSampleCap: z.number().int().positive().optional(),
+  })
+  .strict()
+
 /**
  * UIServerConfiguration — UI server configuration section.
  * `options` is structurally typed as `ListenOptions` from node:net and
@@ -223,6 +239,7 @@ export const UIServerConfigurationSchema = z
     accessPolicy: UIServerAccessPolicySchema.optional(),
     authentication: UIServerAuthenticationSchema.optional(),
     enabled: z.boolean().optional(),
+    metrics: UIServerMetricsConfigurationSchema.optional(),
     options: UIServerListenOptionsSchema.optional(),
     type: z.enum(ApplicationProtocol).optional(),
     version: z.enum(ApplicationProtocolVersion).optional(),
index c1642c6fcf6444a4670430b11913d83981ec58ad..d915ca86ad1fa16fd6b28759bdbfbfbd9cfe2a02 100644 (file)
@@ -25,6 +25,7 @@ export {
   UIServerAccessPolicySchema,
   UIServerAuthenticationSchema,
   UIServerConfigurationSchema,
+  UIServerMetricsConfigurationSchema,
   WorkerConfigurationSchema,
 } from './ConfigurationSchema.js'
 export {
diff --git a/tests/charging-station/ui-server/UIMetricsEndpoint.test.ts b/tests/charging-station/ui-server/UIMetricsEndpoint.test.ts
new file mode 100644 (file)
index 0000000..34f994e
--- /dev/null
@@ -0,0 +1,740 @@
+/**
+ * @file Tests for the Prometheus /metrics endpoint on UIHttpServer (issue #851)
+ * @description End-to-end behavior, security inheritance, PII reject-list, exposition-format escaping and the cardinality soft cap warning.
+ */
+
+import type { IncomingMessage } from 'node:http'
+import type { mock } from 'node:test'
+
+import assert from 'node:assert/strict'
+import { once } from 'node:events'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type {
+  ChargingStationData,
+  TemplateStatistics,
+  UIServerConfiguration,
+} from '../../../src/types/index.js'
+
+import { AbstractUIServer } from '../../../src/charging-station/ui-server/AbstractUIServer.js'
+import {
+  METRICS_SOFT_SAMPLE_CAP,
+  UIHttpServer,
+} from '../../../src/charging-station/ui-server/UIHttpServer.js'
+import { UIWebSocketServer } from '../../../src/charging-station/ui-server/UIWebSocketServer.js'
+import {
+  ApplicationProtocol,
+  AuthenticationType,
+  ConnectorStatusEnum,
+  OCPP16AvailabilityType,
+  OCPPVersion,
+} from '../../../src/types/index.js'
+import { logger } from '../../../src/utils/index.js'
+import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
+import {
+  createMockBootstrap,
+  createMockIncomingMessage,
+  createMockUIServerConfiguration,
+  MockServerResponse,
+} from './UIServerTestUtils.js'
+
+// eslint-disable-next-line @typescript-eslint/no-deprecated
+class TestableUIHttpServer extends UIHttpServer {
+  public constructor (config: UIServerConfiguration) {
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    super(config, createMockBootstrap())
+  }
+
+  public addStation (data: ChargingStationData): void {
+    this.setChargingStationData(data.stationInfo.hashId, data)
+  }
+
+  public emitRequest (req: IncomingMessage, res: MockServerResponse): void {
+    const httpServer = Reflect.get(this, 'httpServer') as {
+      emit: (eventName: string, req: IncomingMessage, res: MockServerResponse) => boolean
+      listen: (...args: unknown[]) => unknown
+      removeAllListeners: () => void
+    }
+    httpServer.emit('request', req, res)
+  }
+
+  public getMetricsRegistry (): unknown {
+    return Reflect.get(this, 'metricsRegistry')
+  }
+
+  public mockListen (t: { mock: { method: typeof mock.method } }): void {
+    const httpServer = Reflect.get(this, 'httpServer') as object
+    t.mock.method(httpServer as never, 'listen' as never, ((): unknown => httpServer) as never)
+  }
+}
+
+const createMetricsConfig = (
+  overrides: Partial<UIServerConfiguration> = {}
+): UIServerConfiguration =>
+  createMockUIServerConfiguration({
+    metrics: { enabled: true },
+    options: { host: '127.0.0.1', port: 0 },
+    type: ApplicationProtocol.HTTP,
+    ...overrides,
+  })
+
+const buildStationData = (
+  hashId: string,
+  overrides: Partial<ChargingStationData> = {}
+): ChargingStationData =>
+  ({
+    connectors: [
+      {
+        connectorId: 1,
+        connectorStatus: {
+          availability: OCPP16AvailabilityType.Operative,
+          status: ConnectorStatusEnum.Available,
+          transactionStarted: false,
+        },
+        evseId: 1,
+      },
+    ],
+    evses: [],
+    ocppConfiguration: { configurationKey: [] },
+    started: true,
+    stationInfo: {
+      chargePointModel: 'TestModel',
+      chargePointVendor: 'TestVendor',
+      chargingStationId: hashId,
+      hashId,
+      maximumAmperage: 32,
+      maximumPower: 22000,
+      ocppVersion: OCPPVersion.VERSION_16,
+      templateIndex: 0,
+      templateName: 'test-template',
+    },
+    supervisionUrl: 'ws://test.example.com/OCPP16',
+    timestamp: 1_700_000_000_000,
+    wsState: 1,
+    ...overrides,
+  }) as ChargingStationData
+
+const enrichBootstrap = (server: TestableUIHttpServer, version = '4.9.0'): void => {
+  const bootstrap = server.getBootstrap()
+  const templateStats: TemplateStatistics = {
+    added: 1,
+    configured: 5,
+    indexes: new Set([0]),
+    provisioned: 2,
+    started: 1,
+  }
+  Reflect.set(bootstrap, 'getState', () => ({
+    configuration: undefined,
+    started: true,
+    templateStatistics: new Map<string, TemplateStatistics>([['test-template', templateStats]]),
+    version,
+  }))
+}
+
+const buildMetricsRequest = (overrides: Partial<IncomingMessage> = {}): IncomingMessage =>
+  createMockIncomingMessage({
+    complete: true,
+    headers: { host: 'localhost' },
+    method: 'GET',
+    socket: { encrypted: false, remoteAddress: '127.0.0.1' } as never,
+    url: '/metrics',
+    ...overrides,
+  })
+
+await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
+  let server: TestableUIHttpServer
+
+  beforeEach(() => {
+    server = new TestableUIHttpServer(createMetricsConfig())
+    enrichBootstrap(server)
+  })
+
+  afterEach(() => {
+    server.stop()
+    standardCleanup()
+  })
+
+  await it('should serve Prometheus exposition on GET /metrics when enabled', async t => {
+    server.mockListen(t)
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    server.start()
+    const res = new MockServerResponse()
+    server.emitRequest(buildMetricsRequest(), res)
+    await once(res, 'finish')
+    assert.strictEqual(res.statusCode, 200)
+    assert.match(res.headers['Content-Type'] ?? '', /^text\/plain;\s*version=0\.0\.4/)
+    assert.match(res.body ?? '', /^# HELP /m)
+    assert.match(res.body ?? '', /^# TYPE /m)
+  })
+
+  await it('should fall through to 400 on GET /metrics when metrics block is absent', t => {
+    const plainServer = new TestableUIHttpServer(
+      createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP })
+    )
+    enrichBootstrap(plainServer)
+    plainServer.mockListen(t)
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-deprecated
+      plainServer.start()
+      const res = new MockServerResponse()
+      plainServer.emitRequest(buildMetricsRequest(), res)
+      assert.strictEqual(res.statusCode, 400)
+    } finally {
+      plainServer.stop()
+    }
+  })
+
+  await it('should fall through to 400 on GET /metrics when metrics.enabled is false', t => {
+    const offServer = new TestableUIHttpServer(
+      createMockUIServerConfiguration({
+        metrics: { enabled: false },
+        type: ApplicationProtocol.HTTP,
+      })
+    )
+    enrichBootstrap(offServer)
+    offServer.mockListen(t)
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-deprecated
+      offServer.start()
+      const res = new MockServerResponse()
+      offServer.emitRequest(buildMetricsRequest(), res)
+      assert.strictEqual(res.statusCode, 400)
+    } finally {
+      offServer.stop()
+    }
+  })
+
+  await it('should serve global gauges from Bootstrap.getState().templateStatistics', async t => {
+    server.mockListen(t)
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    server.start()
+    const res = new MockServerResponse()
+    server.emitRequest(buildMetricsRequest(), res)
+    await once(res, 'finish')
+    const body = res.body ?? ''
+    assert.match(body, /^simulator_charging_stations_configured_total\s+5$/m)
+    assert.match(body, /^simulator_charging_stations_provisioned_total\s+2$/m)
+    assert.match(body, /^simulator_charging_stations_added_total\s+1$/m)
+    assert.match(body, /^simulator_charging_stations_started_total\s+1$/m)
+    assert.match(body, /^simulator_charging_station_templates_total\s+1$/m)
+  })
+
+  await it('should serve per-station gauges from chargingStations Map', async t => {
+    server.addStation(buildStationData('station-T5'))
+    server.mockListen(t)
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    server.start()
+    const res = new MockServerResponse()
+    server.emitRequest(buildMetricsRequest(), res)
+    await once(res, 'finish')
+    const body = res.body ?? ''
+    assert.match(body, /simulator_station_started\{[^}]*hash_id="station-T5"[^}]*\}\s+1/)
+    assert.match(body, /simulator_station_ws_state\{[^}]*hash_id="station-T5"[^}]*\}\s+1/)
+    assert.match(body, /simulator_station_connectors_total\{[^}]*hash_id="station-T5"[^}]*\}\s+1/)
+  })
+
+  await it('should serve per-connector status_info one-hot', async t => {
+    server.addStation(buildStationData('station-T6'))
+    server.mockListen(t)
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    server.start()
+    const res = new MockServerResponse()
+    server.emitRequest(buildMetricsRequest(), res)
+    await once(res, 'finish')
+    const body = res.body ?? ''
+    const line = body
+      .split('\n')
+      .find(l => l.startsWith('simulator_connector_status_info{') && l.endsWith(' 1'))
+    assert.ok(line != null, 'simulator_connector_status_info value line not found')
+    assert.match(line, /hash_id="station-T6"/)
+    assert.match(line, /connector_id="1"/)
+    assert.match(line, /status="Available"/)
+  })
+
+  await it('should reject POST /metrics with non-200 (existing 400 path)', t => {
+    server.mockListen(t)
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    server.start()
+    const res = new MockServerResponse()
+    server.emitRequest(buildMetricsRequest({ method: 'POST' }), res)
+    assert.notStrictEqual(res.statusCode, 200)
+  })
+
+  await it('should inherit AccessPolicy denial — 403 on non-loopback without TLS', t => {
+    const gatedServer = new TestableUIHttpServer(
+      createMetricsConfig({
+        accessPolicy: {
+          allowedHosts: ['gateway.example.com'],
+          allowedOrigins: [],
+          allowLoopbackProxy: false,
+          requireTlsForNonLoopback: true,
+          trustedProxies: [],
+        },
+      })
+    )
+    enrichBootstrap(gatedServer)
+    gatedServer.mockListen(t)
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-deprecated
+      gatedServer.start()
+      const res = new MockServerResponse()
+      gatedServer.emitRequest(
+        buildMetricsRequest({
+          headers: { host: 'gateway.example.com' },
+          socket: { encrypted: false, remoteAddress: '203.0.113.10' } as never,
+        }),
+        res
+      )
+      assert.strictEqual(res.statusCode, 403)
+    } finally {
+      gatedServer.stop()
+    }
+  })
+
+  await it('should inherit rate-limit — eventual 429 on burst', t => {
+    server.mockListen(t)
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    server.start()
+    const statuses: (number | undefined)[] = []
+    for (let i = 0; i < 200; i++) {
+      const res = new MockServerResponse()
+      server.emitRequest(buildMetricsRequest(), res)
+      statuses.push(res.statusCode)
+    }
+    assert.ok(
+      statuses.some(s => s === 429),
+      `Expected at least one 429 in burst on allowed /metrics path; saw ${JSON.stringify(statuses.slice(0, 5))}…`
+    )
+  })
+
+  await it('should inherit BASIC_AUTH — 401 on missing credentials', t => {
+    const authServer = new TestableUIHttpServer(
+      createMetricsConfig({
+        authentication: {
+          enabled: true,
+          password: 'pw',
+          type: AuthenticationType.BASIC_AUTH,
+          username: 'user',
+        },
+      })
+    )
+    enrichBootstrap(authServer)
+    authServer.mockListen(t)
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-deprecated
+      authServer.start()
+      const res = new MockServerResponse()
+      authServer.emitRequest(buildMetricsRequest(), res)
+      assert.strictEqual(res.statusCode, 401)
+      assert.strictEqual(res.headers['WWW-Authenticate'], 'Basic realm=users')
+    } finally {
+      authServer.stop()
+    }
+  })
+
+  await it('should inherit BASIC_AUTH — 200 on valid credentials', async t => {
+    const authServer = new TestableUIHttpServer(
+      createMetricsConfig({
+        authentication: {
+          enabled: true,
+          password: 'pw',
+          type: AuthenticationType.BASIC_AUTH,
+          username: 'user',
+        },
+      })
+    )
+    enrichBootstrap(authServer)
+    authServer.mockListen(t)
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-deprecated
+      authServer.start()
+      const credentials = Buffer.from('user:pw').toString('base64')
+      const res = new MockServerResponse()
+      authServer.emitRequest(
+        buildMetricsRequest({
+          headers: { authorization: `Basic ${credentials}`, host: 'localhost' },
+        }),
+        res
+      )
+      await once(res, 'finish')
+      assert.strictEqual(res.statusCode, 200)
+    } finally {
+      authServer.stop()
+    }
+  })
+
+  await it('should not leak PII (idTag, serial, supervisionUrl) in body', async t => {
+    server.addStation(
+      buildStationData('station-T12', {
+        connectors: [
+          {
+            connectorId: 1,
+            connectorStatus: {
+              authorizeIdTag: 'SECRET-IDTAG-12345',
+              availability: OCPP16AvailabilityType.Operative,
+              localAuthorizeIdTag: 'SECRET-LOCAL-IDTAG',
+              MeterValues: [],
+              status: ConnectorStatusEnum.Available,
+              transactionIdTag: 'SECRET-TX-IDTAG',
+            },
+            evseId: 1,
+          },
+        ],
+        stationInfo: {
+          baseName: 'test',
+          chargeBoxSerialNumber: 'SECRET-SERIAL-1',
+          chargePointModel: 'TestModel',
+          chargePointSerialNumber: 'SECRET-CP-SERIAL',
+          chargePointVendor: 'TestVendor',
+          chargingStationId: 'station-T12',
+          hashId: 'station-T12',
+          iccid: 'SECRET-ICCID',
+          imsi: 'SECRET-IMSI',
+          meterSerialNumber: 'SECRET-METER',
+          ocppVersion: OCPPVersion.VERSION_16,
+          templateIndex: 0,
+          templateName: 'test-template',
+        },
+        supervisionUrl: 'ws://user:password@evil.example.com/OCPP',
+      })
+    )
+    server.mockListen(t)
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    server.start()
+    const res = new MockServerResponse()
+    server.emitRequest(buildMetricsRequest(), res)
+    await once(res, 'finish')
+    const body = res.body ?? ''
+    for (const secret of [
+      'SECRET-IDTAG-12345',
+      'SECRET-LOCAL-IDTAG',
+      'SECRET-TX-IDTAG',
+      'SECRET-SERIAL-1',
+      'SECRET-CP-SERIAL',
+      'SECRET-METER',
+      'SECRET-ICCID',
+      'SECRET-IMSI',
+      'user:password',
+      'evil.example.com',
+    ]) {
+      assert.ok(!body.includes(secret), `Body must not contain '${secret}'`)
+    }
+    assert.ok(!body.includes('://'), 'Body must not contain any URL scheme')
+  })
+
+  await it('should escape adversarial label values (no injected # HELP)', async t => {
+    server.addStation(
+      buildStationData('station-T13', {
+        stationInfo: {
+          baseName: 'test',
+          chargePointModel: 'TestModel',
+          chargePointVendor: 'TestVendor',
+          chargingStationId:
+            'evil"\n# HELP fake_metric injected\n# TYPE fake_metric gauge\nfake_metric 999\n',
+          hashId: 'station-T13',
+          ocppVersion: OCPPVersion.VERSION_16,
+          templateIndex: 0,
+          templateName: 'test-template',
+        },
+      })
+    )
+    server.mockListen(t)
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    server.start()
+    const res = new MockServerResponse()
+    server.emitRequest(buildMetricsRequest(), res)
+    await once(res, 'finish')
+    const body = res.body ?? ''
+    assert.ok(
+      !/^fake_metric\b/m.test(body),
+      'Adversarial label injection produced a fake metric line'
+    )
+    assert.ok(
+      !/^# HELP fake_metric/m.test(body),
+      'Adversarial label injection produced a fake HELP line'
+    )
+  })
+
+  await it('should not register a UUID in responseHandlers', t => {
+    server.mockListen(t)
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    server.start()
+    const res = new MockServerResponse()
+    server.emitRequest(buildMetricsRequest(), res)
+    const responseHandlers = Reflect.get(server, 'responseHandlers') as Map<string, unknown>
+    assert.strictEqual(responseHandlers.size, 0)
+  })
+
+  await it('should clear registry on stop()', t => {
+    server.mockListen(t)
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    server.start()
+    server.stop()
+    assert.strictEqual(server.getMetricsRegistry(), undefined)
+  })
+
+  await it('should fire soft-warn when sample count exceeds METRICS_SOFT_SAMPLE_CAP', async t => {
+    // Each station emits ≈ 14 samples on the per-station gauges (no connectors yet)
+    // plus 1 sample on info, that is ~15 per station. To cross 5 000 we add 400+ stations.
+    const stationCount = Math.max(400, Math.ceil(METRICS_SOFT_SAMPLE_CAP / 15) + 50)
+    for (let i = 0; i < stationCount; i++) {
+      server.addStation(buildStationData(`station-T16-${i.toString()}`))
+    }
+    const warnSpy = t.mock.method(logger, 'warn', () => undefined)
+    server.mockListen(t)
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    server.start()
+    const res = new MockServerResponse()
+    server.emitRequest(buildMetricsRequest(), res)
+    await once(res, 'finish')
+    assert.strictEqual(res.statusCode, 200)
+    const matchingCalls = warnSpy.mock.calls.filter(call => {
+      const message: unknown = call.arguments[0]
+      return typeof message === 'string' && message.includes('soft cap')
+    })
+    assert.ok(
+      matchingCalls.length >= 1,
+      `Expected at least one logger.warn 'soft cap' call after ${stationCount.toString()} stations; got ${warnSpy.mock.calls.length.toString()} warn calls total`
+    )
+  })
+
+  await it('should omit simulator_station_ws_state line when wsState is undefined', async t => {
+    server.addStation(buildStationData('station-Mws', { wsState: undefined }))
+    server.mockListen(t)
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    server.start()
+    const res = new MockServerResponse()
+    server.emitRequest(buildMetricsRequest(), res)
+    await once(res, 'finish')
+    const body = res.body ?? ''
+    assert.ok(
+      !/simulator_station_ws_state\{[^}]*hash_id="station-Mws"[^}]*\}/.test(body),
+      'simulator_station_ws_state line must be absent when wsState is undefined'
+    )
+    assert.match(body, /simulator_station_started\{[^}]*hash_id="station-Mws"[^}]*\}\s+1/)
+  })
+
+  await it('should serve per-connector metrics in EVSE-mode (OCPP 2.0.x) station', async t => {
+    server.addStation(
+      buildStationData('station-T18', {
+        connectors: [],
+        evses: [
+          {
+            evseId: 1,
+            evseStatus: {
+              availability: OCPP16AvailabilityType.Operative,
+              connectors: new Map([
+                [
+                  1,
+                  {
+                    availability: OCPP16AvailabilityType.Operative,
+                    MeterValues: [],
+                    status: ConnectorStatusEnum.Available,
+                  },
+                ],
+              ]),
+              MeterValues: [],
+            },
+          },
+        ] as ChargingStationData['evses'],
+      })
+    )
+    server.mockListen(t)
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    server.start()
+    const res = new MockServerResponse()
+    server.emitRequest(buildMetricsRequest(), res)
+    await once(res, 'finish')
+    const body = res.body ?? ''
+    assert.match(body, /simulator_station_connectors_total\{[^}]*hash_id="station-T18"[^}]*\}\s+1/)
+    const statusLine = body
+      .split('\n')
+      .find(
+        l =>
+          l.startsWith('simulator_connector_status_info{') &&
+          l.includes('hash_id="station-T18"') &&
+          l.endsWith(' 1')
+      )
+    assert.ok(statusLine != null, 'simulator_connector_status_info value line not found')
+    assert.match(statusLine, /connector_id="1"/)
+    assert.match(statusLine, /status="Available"/)
+  })
+
+  await it('should detect off-by-one at soft cap boundary (strict-greater-than semantics)', async t => {
+    const warnSpy = t.mock.method(logger, 'warn', () => undefined)
+
+    // Phase 1: probe — very high cap, count actual samples produced (no warn expected).
+    const probeServer = new TestableUIHttpServer(
+      createMetricsConfig({ metrics: { enabled: true, softSampleCap: 1_000_000 } })
+    )
+    enrichBootstrap(probeServer)
+    for (let i = 0; i < 5; i++) {
+      probeServer.addStation(buildStationData(`station-T19-probe-${i.toString()}`))
+    }
+    probeServer.mockListen(t)
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    probeServer.start()
+    const probeRes = new MockServerResponse()
+    probeServer.emitRequest(buildMetricsRequest(), probeRes)
+    await once(probeRes, 'finish')
+    const probedSampleCount = (probeRes.body ?? '')
+      .split('\n')
+      .filter(line => line.length > 0 && !line.startsWith('#')).length
+    probeServer.stop()
+    warnSpy.mock.resetCalls()
+    assert.ok(probedSampleCount > 0, 'probe scrape produced no samples')
+
+    // Phase 2: cap === probedSampleCount → NO warn (count IS NOT > cap, strict).
+    const exactServer = new TestableUIHttpServer(
+      createMetricsConfig({ metrics: { enabled: true, softSampleCap: probedSampleCount } })
+    )
+    enrichBootstrap(exactServer)
+    for (let i = 0; i < 5; i++) {
+      exactServer.addStation(buildStationData(`station-T19-exact-${i.toString()}`))
+    }
+    exactServer.mockListen(t)
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    exactServer.start()
+    const exactRes = new MockServerResponse()
+    exactServer.emitRequest(buildMetricsRequest(), exactRes)
+    await once(exactRes, 'finish')
+    const exactSoftCapCalls = warnSpy.mock.calls.filter(call => {
+      const message: unknown = call.arguments[0]
+      return typeof message === 'string' && message.includes('soft cap')
+    }).length
+    exactServer.stop()
+    assert.strictEqual(
+      exactSoftCapCalls,
+      0,
+      `Expected 0 'soft cap' warns at exact boundary (count=cap=${probedSampleCount.toString()}); got ${exactSoftCapCalls.toString()} — would fail if '>' becomes '>='`
+    )
+    warnSpy.mock.resetCalls()
+
+    // Phase 3: cap === probedSampleCount - 1 → WARN (count IS > cap).
+    const belowServer = new TestableUIHttpServer(
+      createMetricsConfig({
+        metrics: { enabled: true, softSampleCap: probedSampleCount - 1 },
+      })
+    )
+    enrichBootstrap(belowServer)
+    for (let i = 0; i < 5; i++) {
+      belowServer.addStation(buildStationData(`station-T19-below-${i.toString()}`))
+    }
+    belowServer.mockListen(t)
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    belowServer.start()
+    const belowRes = new MockServerResponse()
+    belowServer.emitRequest(buildMetricsRequest(), belowRes)
+    await once(belowRes, 'finish')
+    const belowSoftCapCalls = warnSpy.mock.calls.filter(call => {
+      const message: unknown = call.arguments[0]
+      return typeof message === 'string' && message.includes('soft cap')
+    }).length
+    belowServer.stop()
+    assert.ok(
+      belowSoftCapCalls >= 1,
+      `Expected ≥1 'soft cap' warn when cap=${(probedSampleCount - 1).toString()} < count=${probedSampleCount.toString()}; got ${belowSoftCapCalls.toString()}`
+    )
+  })
+
+  await it('should serialize concurrent /metrics scrapes (no shared-counter race)', async t => {
+    // R1+R2 lock: two simultaneous GET /metrics must each produce a complete,
+    // well-formed body and a coherent sample count. Without `metricsScrapeChain`
+    // serialization, both scrapes' `collect()` callbacks would interleave on
+    // `metricsSampleCount`, racing the soft cap check and corrupting the
+    // exposition body. Configure the cap to the per-scrape sample count so an
+    // honest serialized run produces ZERO warns; a broken (concurrent) run
+    // would either spuriously warn (counter doubled) or truncate.
+    const probeServer = new TestableUIHttpServer(
+      createMetricsConfig({ metrics: { enabled: true, softSampleCap: 1_000_000 } })
+    )
+    enrichBootstrap(probeServer)
+    for (let i = 0; i < 5; i++) {
+      probeServer.addStation(buildStationData(`station-T20-probe-${i.toString()}`))
+    }
+    probeServer.mockListen(t)
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    probeServer.start()
+    const probeRes = new MockServerResponse()
+    probeServer.emitRequest(buildMetricsRequest(), probeRes)
+    await once(probeRes, 'finish')
+    const probedSampleCount = (probeRes.body ?? '')
+      .split('\n')
+      .filter(line => line.length > 0 && !line.startsWith('#')).length
+    probeServer.stop()
+    assert.ok(probedSampleCount > 0, 'probe scrape produced no samples')
+
+    const warnSpy = t.mock.method(logger, 'warn', () => undefined)
+    const concurrentServer = new TestableUIHttpServer(
+      createMetricsConfig({ metrics: { enabled: true, softSampleCap: probedSampleCount } })
+    )
+    enrichBootstrap(concurrentServer)
+    for (let i = 0; i < 5; i++) {
+      concurrentServer.addStation(buildStationData(`station-T20-${i.toString()}`))
+    }
+    concurrentServer.mockListen(t)
+    // eslint-disable-next-line @typescript-eslint/no-deprecated
+    concurrentServer.start()
+
+    const resA = new MockServerResponse()
+    const resB = new MockServerResponse()
+    concurrentServer.emitRequest(buildMetricsRequest(), resA)
+    concurrentServer.emitRequest(buildMetricsRequest(), resB)
+    await Promise.all([once(resA, 'finish'), once(resB, 'finish')])
+    concurrentServer.stop()
+
+    assert.strictEqual(resA.statusCode, 200)
+    assert.strictEqual(resB.statusCode, 200)
+    const bodyA = resA.body ?? ''
+    const bodyB = resB.body ?? ''
+    const sampleLines = (body: string): number =>
+      body.split('\n').filter(line => line.length > 0 && !line.startsWith('#')).length
+    assert.strictEqual(
+      sampleLines(bodyA),
+      probedSampleCount,
+      `scrape A must emit exactly ${probedSampleCount.toString()} sample lines (no truncation, no double-count); got ${sampleLines(bodyA).toString()}`
+    )
+    assert.strictEqual(
+      sampleLines(bodyB),
+      probedSampleCount,
+      `scrape B must emit exactly ${probedSampleCount.toString()} sample lines (no truncation, no double-count); got ${sampleLines(bodyB).toString()}`
+    )
+    const softCapCalls = warnSpy.mock.calls.filter(call => {
+      const message: unknown = call.arguments[0]
+      return typeof message === 'string' && message.includes('soft cap')
+    }).length
+    assert.strictEqual(
+      softCapCalls,
+      0,
+      `Expected 0 'soft cap' warns under serialized concurrent scrapes (cap=count=${probedSampleCount.toString()}); got ${softCapCalls.toString()} — would fail if metricsScrapeChain serialization were removed`
+    )
+  })
+
+  await it('should fire warnIfMisconfigured when metrics.enabled=true && type=ws', t => {
+    const warnSpy = t.mock.method(logger, 'warn', () => undefined)
+    const wsServer = new UIWebSocketServer(
+      createMockUIServerConfiguration({
+        metrics: { enabled: true },
+        options: { host: 'localhost', port: 0 },
+        type: ApplicationProtocol.WS,
+      }),
+      createMockBootstrap()
+    )
+    try {
+      const matchingCalls = warnSpy.mock.calls.filter(call => {
+        const message: unknown = call.arguments[0]
+        return (
+          typeof message === 'string' && message.includes('metrics') && message.includes('http')
+        )
+      })
+      assert.ok(
+        matchingCalls.length >= 1,
+        `Expected logger.warn about metrics.enabled on non-HTTP transport; saw ${warnSpy.mock.calls.length.toString()} total`
+      )
+    } finally {
+      wsServer.stop()
+      // satisfy AbstractUIServer reference (no-op outside this scope)
+      void AbstractUIServer
+    }
+  })
+})