From 016fc93279bfcfb07ef0dab0985e4c6451a39ac2 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sat, 20 Jun 2026 20:47:27 +0200 Subject: [PATCH] feat(ui-server): add opt-in Prometheus /metrics endpoint (closes #851) (#1912) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * 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: ...' to 'should ...' 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 with auto-injected registers and a string-literal label-name generic. Every collect() callback is non-arrow with typed 'this: Gauge', 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 default in the JSDoc (stricter than prom-client's 'Gauge'). - 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' 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' numeric convention ('station-T5', 'station-T12', 'station-T13', 'station-T16'), where 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// 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 | 1 + README.md | 22 +- package.json | 1 + pnpm-lock.yaml | 37 +- .../ui-server/AbstractUIServer.ts | 15 + .../ui-server/UIHttpServer.ts | 833 ++++++++++++++++++ .../ui-server/UIServerUtils.ts | 1 + src/types/ConfigurationData.ts | 2 + src/utils/ConfigurationSchema.ts | 17 + src/utils/index.ts | 1 + .../ui-server/UIMetricsEndpoint.test.ts | 740 ++++++++++++++++ 11 files changed, 1656 insertions(+), 14 deletions(-) create mode 100644 tests/charging-station/ui-server/UIMetricsEndpoint.test.ts diff --git a/.gitignore b/.gitignore index 93e55493..a64df1c8 100644 --- a/.gitignore +++ b/.gitignore @@ -101,6 +101,7 @@ build/config.gypi Thumbs.db # oh-my-opencode +.codegraph .omo/ .sisyphus/ diff --git a/README.md b/README.md index 495532a4..fa2c5dd7 100644 --- 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 | | {
"enabled": true,
"file": "logs/combined.log",
"errorFile": "logs/error.log",
"statisticsInterval": 60,
"level": "info",
"console": false,
"format": "simple",
"rotate": true
} | {
enabled?: boolean;
file?: string;
errorFile?: string;
statisticsInterval?: number;
level?: string;
console?: boolean;
format?: string;
rotate?: boolean;
maxFiles?: string \| number;
maxSize?: string \| number;
} | Log configuration section:
- _enabled_: enable logging
- _file_: log file relative path
- _errorFile_: error log file relative path
- _statisticsInterval_: seconds between charging stations statistics output in the logs
- _level_: emerg/alert/crit/error/warning/notice/info/debug [winston](https://github.com/winstonjs/winston) logging level
- _console_: output logs on the console
- _format_: [winston](https://github.com/winstonjs/winston) log format
- _rotate_: enable daily log files rotation
- _maxFiles_: maximum number of log files: https://github.com/winstonjs/winston-daily-rotate-file#options
- _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 | | {
"processType": "workerSet",
"startDelay": 500,
"elementAddDelay": 0,
"elementsPerWorker": 'auto',
"poolMinSize": 4,
"poolMaxSize": 16
} | {
processType?: WorkerProcessType;
startDelay?: number;
elementAddDelay?: number;
elementsPerWorker?: number \| 'auto' \| 'all';
poolMinSize?: number;
poolMaxSize?: number;
resourceLimits?: ResourceLimits;
} | Worker configuration section:
- _processType_: worker threads process type (`workerSet`/`fixedPool`/`dynamicPool`)
- _startDelay_: milliseconds to wait at worker threads startup (only for `workerSet` worker threads process type)
- _elementAddDelay_: milliseconds to wait between charging station add
- _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)
- _poolMinSize_: worker threads pool minimum number of threads
- _poolMaxSize_: worker threads pool maximum number of threads
- _resourceLimits_: worker threads [resource limits](https://nodejs.org/api/worker_threads.html#new-workerfilename-options) object option | -| uiServer | | {
"enabled": false,
"type": "ws",
"version": "1.1",
"accessPolicy": {
"requireTlsForNonLoopback": true,
"trustedProxies": [],
"allowLoopbackProxy": false,
"allowedHosts": [],
"allowedOrigins": []
},
"options": {
"host": "localhost",
"port": 8080
}
} | {
enabled?: boolean;
type?: ApplicationProtocol;
version?: ApplicationProtocolVersion;
accessPolicy?: {
requireTlsForNonLoopback?: boolean;
trustedProxies?: string[];
allowLoopbackProxy?: boolean;
allowedHosts?: string[];
allowedOrigins?: string[];
};
options?: ServerOptions;
authentication?: {
enabled: boolean;
type: AuthenticationType;
username?: string;
password?: string;
}
} | UI server configuration section:
- _enabled_: enable UI server
- _type_: 'ws', 'mcp' or 'http' (deprecated)
- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')
- _accessPolicy_: gateway access policy. Loopback request sources are allowed in plaintext; non-loopback sources require TLS termination by a reverse proxy:
  - _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`
  - _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`
  - _allowLoopbackProxy_: accept forwarded headers when the immediate peer is loopback AND listed in _trustedProxies_ (e.g. `['127.0.0.1', '::1']`)
  - _allowedHosts_: explicit Host header allowlist; mitigates DNS rebinding when the UI server is exposed through a browser-facing host
  - _allowedOrigins_: explicit Origin header allowlist; when empty, the request Origin's URL hostname falls back to matching against _allowedHosts_
- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)
- _authentication_: authentication type configuration section | -| performanceStorage | | {
"enabled": true,
"type": "none",
} | {
enabled?: boolean;
type?: string;
uri?: string;
} | Performance storage configuration section:
- _enabled_: enable performance storage
- _type_: 'jsonfile', 'mongodb' or 'none'
- _uri_: storage URI | -| stationTemplateUrls | | {}[] | {
file: string;
numberOfStations: number;
provisionedNumberOfStations?: number;
}[] | array of charging station templates URIs configuration section:
- _file_: charging station configuration template file relative path
- _numberOfStations_: template number of stations at startup
- _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 | | {
"enabled": true,
"file": "logs/combined.log",
"errorFile": "logs/error.log",
"statisticsInterval": 60,
"level": "info",
"console": false,
"format": "simple",
"rotate": true
} | {
enabled?: boolean;
file?: string;
errorFile?: string;
statisticsInterval?: number;
level?: string;
console?: boolean;
format?: string;
rotate?: boolean;
maxFiles?: string \| number;
maxSize?: string \| number;
} | Log configuration section:
- _enabled_: enable logging
- _file_: log file relative path
- _errorFile_: error log file relative path
- _statisticsInterval_: seconds between charging stations statistics output in the logs
- _level_: emerg/alert/crit/error/warning/notice/info/debug [winston](https://github.com/winstonjs/winston) logging level
- _console_: output logs on the console
- _format_: [winston](https://github.com/winstonjs/winston) log format
- _rotate_: enable daily log files rotation
- _maxFiles_: maximum number of log files: https://github.com/winstonjs/winston-daily-rotate-file#options
- _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 | | {
"processType": "workerSet",
"startDelay": 500,
"elementAddDelay": 0,
"elementsPerWorker": 'auto',
"poolMinSize": 4,
"poolMaxSize": 16
} | {
processType?: WorkerProcessType;
startDelay?: number;
elementAddDelay?: number;
elementsPerWorker?: number \| 'auto' \| 'all';
poolMinSize?: number;
poolMaxSize?: number;
resourceLimits?: ResourceLimits;
} | Worker configuration section:
- _processType_: worker threads process type (`workerSet`/`fixedPool`/`dynamicPool`)
- _startDelay_: milliseconds to wait at worker threads startup (only for `workerSet` worker threads process type)
- _elementAddDelay_: milliseconds to wait between charging station add
- _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)
- _poolMinSize_: worker threads pool minimum number of threads
- _poolMaxSize_: worker threads pool maximum number of threads
- _resourceLimits_: worker threads [resource limits](https://nodejs.org/api/worker_threads.html#new-workerfilename-options) object option | +| uiServer | | {
"enabled": false,
"type": "ws",
"version": "1.1",
"accessPolicy": {
"requireTlsForNonLoopback": true,
"trustedProxies": [],
"allowLoopbackProxy": false,
"allowedHosts": [],
"allowedOrigins": []
},
"options": {
"host": "localhost",
"port": 8080
}
} | {
enabled?: boolean;
type?: ApplicationProtocol;
version?: ApplicationProtocolVersion;
accessPolicy?: {
requireTlsForNonLoopback?: boolean;
trustedProxies?: string[];
allowLoopbackProxy?: boolean;
allowedHosts?: string[];
allowedOrigins?: string[];
};
options?: ServerOptions;
authentication?: {
enabled: boolean;
type: AuthenticationType;
username?: string;
password?: string;
};
metrics?: {
enabled?: boolean;
softSampleCap?: number;
};
} | UI server configuration section:
- _enabled_: enable UI server
- _type_: 'ws', 'mcp' or 'http' (deprecated)
- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')
- _accessPolicy_: gateway access policy. Loopback request sources are allowed in plaintext; non-loopback sources require TLS termination by a reverse proxy:
  - _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`
  - _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`
  - _allowLoopbackProxy_: accept forwarded headers when the immediate peer is loopback AND listed in _trustedProxies_ (e.g. `['127.0.0.1', '::1']`)
  - _allowedHosts_: explicit Host header allowlist; mitigates DNS rebinding when the UI server is exposed through a browser-facing host
  - _allowedOrigins_: explicit Origin header allowlist; when empty, the request Origin's URL hostname falls back to matching against _allowedHosts_
- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)
- _authentication_: authentication type configuration section
- _metrics_: opt-in Prometheus `/metrics` endpoint (HTTP transport only):
  - _enabled_: enable the `/metrics` endpoint
  - _softSampleCap_: soft cardinality cap above which a single warn is logged per scrape (default 5000) | +| performanceStorage | | {
"enabled": true,
"type": "none",
} | {
enabled?: boolean;
type?: string;
uri?: string;
} | Performance storage configuration section:
- _enabled_: enable performance storage
- _type_: 'jsonfile', 'mongodb' or 'none'
- _uri_: storage URI | +| stationTemplateUrls | | {}[] | {
file: string;
numberOfStations: number;
provisionedNumberOfStations?: number;
}[] | array of charging station templates URIs configuration section:
- _file_: charging station configuration template file relative path
- _numberOfStations_: template number of stations at startup
- _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 diff --git a/package.json b/package.json index 4c99a025..78cb157f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 431ad054..8e397f52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/charging-station/ui-server/AbstractUIServer.ts b/src/charging-station/ui-server/AbstractUIServer.ts index 6b97e92e..a86e0340 100644 --- a/src/charging-station/ui-server/AbstractUIServer.ts +++ b/src/charging-station/ui-server/AbstractUIServer.ts @@ -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( diff --git a/src/charging-station/ui-server/UIHttpServer.ts b/src/charging-station/ui-server/UIHttpServer.ts index ec4c1d6e..c62fc9be 100644 --- a/src/charging-station/ui-server/UIHttpServer.ts +++ b/src/charging-station/ui-server/UIHttpServer.ts @@ -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 + 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 = 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 { + 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`) 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`. + */ +const defineGauge = ( + registry: Registry, + config: Omit, 'registers'> +): Gauge => new Gauge({ ...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 { + 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, + }) +} diff --git a/src/charging-station/ui-server/UIServerUtils.ts b/src/charging-station/ui-server/UIServerUtils.ts index a2df33d0..52ec53a1 100644 --- a/src/charging-station/ui-server/UIServerUtils.ts +++ b/src/charging-station/ui-server/UIServerUtils.ts @@ -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', diff --git a/src/types/ConfigurationData.ts b/src/types/ConfigurationData.ts index 8e738b81..81318c4a 100644 --- a/src/types/ConfigurationData.ts +++ b/src/types/ConfigurationData.ts @@ -6,6 +6,7 @@ import type { StationTemplateUrlSchema, StorageConfigurationSchema, UIServerConfigurationSchema, + UIServerMetricsConfigurationSchema, WorkerConfigurationSchema, } from '../utils/index.js' @@ -33,4 +34,5 @@ export type LogConfiguration = z.infer export type StationTemplateUrl = z.infer export type StorageConfiguration = z.infer export type UIServerConfiguration = z.infer +export type UIServerMetricsConfiguration = z.infer export type WorkerConfiguration = z.infer diff --git a/src/utils/ConfigurationSchema.ts b/src/utils/ConfigurationSchema.ts index c3bca037..919ca3a4 100644 --- a/src/utils/ConfigurationSchema.ts +++ b/src/utils/ConfigurationSchema.ts @@ -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(), diff --git a/src/utils/index.ts b/src/utils/index.ts index c1642c6f..d915ca86 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -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 index 00000000..34f994e2 --- /dev/null +++ b/tests/charging-station/ui-server/UIMetricsEndpoint.test.ts @@ -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 => + createMockUIServerConfiguration({ + metrics: { enabled: true }, + options: { host: '127.0.0.1', port: 0 }, + type: ApplicationProtocol.HTTP, + ...overrides, + }) + +const buildStationData = ( + hashId: string, + overrides: Partial = {} +): 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([['test-template', templateStats]]), + version, + })) +} + +const buildMetricsRequest = (overrides: Partial = {}): 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 + 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 + } + }) +}) -- 2.53.0