From: Jérôme Benoit Date: Tue, 23 Jun 2026 22:35:14 +0000 (+0200) Subject: feat(ui-server): expose Prometheus /metrics on ws and mcp transports (#1921) X-Git-Tag: cli@v4.10.0~6 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=b56748a3f5e53e5513124f96939b54b425e5af22;p=e-mobility-charging-stations-simulator.git feat(ui-server): expose Prometheus /metrics on ws and mcp transports (#1921) The opt-in Prometheus `/metrics` endpoint introduced by #1912 was wired only into `UIHttpServer`. With `uiServer.type` set to `ws` or `mcp`, the endpoint was silently disabled with a startup warning. Move the metrics registry, the request handler, and the soft-cap accounting from `UIHttpServer` up to `AbstractUIServer`, then expose `/metrics` on the shared `httpServer` from `UIWebSocketServer` and `UIMCPServer` via a single `tryServeMetrics` template method that encapsulates the `metrics.enabled` gate, the path predicate, the authentication step, and the scrape handler. The `uiServer.metrics.{enabled,softSampleCap}` configuration block is preserved; the gauge registry is re-used unchanged; and `accessPolicy`, rate-limiting, and `authentication` apply identically on all three transports. The lifecycle is bracketed by a public `start()` template-method on `AbstractUIServer`; subclasses implement the `attachTransport()` protected hook and may override the symmetric `detachTransport()` hook. A one-shot `transportAttached` guard is set BEFORE `attachTransport()` runs so a throwing hook cannot leave a half-attached server admitting a silent retry; on failure any partially-registered `'request'` / `'upgrade'` listeners on `httpServer` are stripped and the guard is rolled back. `metricsScrapeChain` is reseated to `Promise.resolve()` at every `start()` so cycle N+1 never awaits cycle N's scheduled registry clear; in `runMetricsScrape`, the per-scrape `metricsSampleCount` is captured into a local snapshot BEFORE `await registry.metrics()` yields, so a peer scrape dispatched after a sync `stop()`/`start()` cannot overwrite the count the soft-cap branch reads. The corresponding `stop()` is symmetrically defensive: both `detachTransport()` and each `uiService.stop()` are wrapped in per-iteration `try/catch` so a throwing override does not abort downstream teardown (`stopHttpServer`, remaining services, handlers, caches), and the `transportAttached = false` reset runs in a `finally` block so a subsequent `start()` is never permanently locked out — any other unexpected throw still propagates. A shared `renderNotFoundAndDestroy(req, res)` helper consolidates the 404 fallback used by the WebSocket and MCP request listeners; the helper delegates socket teardown to `destroyHttp1SocketIfPending(req)` which skips `req.destroy()` on HTTP/2 streams (lifecycle owned by the `Http2Stream`) and on already-complete HTTP/1.1 requests (keep-alive pool). No new configuration surface, no new listener: `/metrics` is served on the same `Http2Server | Server` instance that already backs the WebSocket protocol upgrade in `UIWebSocketServer` and the `/mcp` route in `UIMCPServer`. HEAD responses emit an explicit `Content-Length` equal to the GET body byte length (`Buffer.byteLength(body, 'utf8')`) and an empty body, per RFC 9110 §9.3.2. A frozen `METRICS_ALLOWED_LABEL_NAMES` tuple pins the canonical PII surface; the `isMetricsAllowedLabelName` type-guard predicate is the O(1) lookup, and a guardian spec asserts every gauge label name in the rendered registry is admitted. Operator-visible change: `uiServer.metrics.enabled=true` with `type='ws'` or `'mcp'` now serves `/metrics` whereas the previous release silently disabled it. Operators relying on the silent disable must set `metrics.enabled=false` (or omit the block) and audit firewall posture on the UI server port. `HEAD /metrics` (e.g. `curl -I`) returns identical headers to `GET` (including `Content-Length`) with an empty body. README updated. Closes #1917 Refs #851, #1912 --- diff --git a/README.md b/README.md index fa2c5dd7..fd5e1cb9 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;
};
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 | +| 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 (served on the configured `uiServer.type` listener — `http`, `ws` or `mcp`):
  - _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 @@ -196,6 +196,10 @@ But the modifications to test have to be done to the files in the build target d - **dynamicPool** (experimental): Dynamically sized worker pool executing a fixed total number of simulated charging stations +#### Migration notes + +- `uiServer.metrics.enabled=true` now exposes `/metrics` on the listener of the configured `uiServer.type` (`http`, `ws` or `mcp`), inheriting that listener's `accessPolicy` and `authentication`. Operators previously relying on the silent disable when `type='ws'` or `type='mcp'` must set `metrics.enabled=false` (or remove the block) if they do not want the endpoint, and verify firewall posture on the UI server port. `HEAD /metrics` (e.g. `curl -I`) returns identical headers to `GET`, including `Content-Length` measured in UTF-8 bytes of the GET body. + ### Charging station template configuration **src/assets/station-templates/\.json**: diff --git a/src/charging-station/ui-server/AbstractUIServer.ts b/src/charging-station/ui-server/AbstractUIServer.ts index a86e0340..802d6860 100644 --- a/src/charging-station/ui-server/AbstractUIServer.ts +++ b/src/charging-station/ui-server/AbstractUIServer.ts @@ -3,17 +3,19 @@ import type { WebSocket } from 'ws' import { getReasonPhrase, StatusCodes } from 'http-status-codes' import { type IncomingMessage, Server, type ServerResponse } from 'node:http' import { createServer, type Http2Server } from 'node:http2' +import { Gauge, type GaugeConfiguration, Registry } from 'prom-client' import type { IBootstrap } from '../IBootstrap.js' import type { AbstractUIService } from './ui-services/AbstractUIService.js' import { BaseError } from '../../exception/index.js' import { - ApplicationProtocol, ApplicationProtocolVersion, AuthenticationType, type ChargingStationData, ConfigurationSection, + type ConnectorEntry, + type ConnectorStatus, type ProcedureName, type ProtocolRequest, type ProtocolResponse, @@ -38,7 +40,7 @@ import { DEFAULT_RATE_WINDOW_MS, isValidCredential, } from './UIServerSecurity.js' -import { getUsernameAndPasswordFromAuthorizationToken } from './UIServerUtils.js' +import { getUsernameAndPasswordFromAuthorizationToken, HttpMethod } from './UIServerUtils.js' /** * Outcome of {@link AbstractUIServer.runRequestPrologue}. @@ -67,6 +69,86 @@ const HTTP_REQUEST_TIMEOUT_MS = 30_000 const moduleName = 'AbstractUIServer' +/** + * 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 + +/** + * Stable substring prefix of the soft-cap warn message emitted by + * {@link AbstractUIServer.runMetricsScrape}. Exported so regression specs + * filter `logger.warn` calls without hard-coding the marketing wording. + */ +export const METRICS_SOFT_CAP_WARN_PREFIX = 'Prometheus scrape produced' + +/** + * Frozen tuple of every label name a Prometheus gauge defined by + * {@link AbstractUIServer.buildMetricsRegistry} is permitted to emit. + * `Object.freeze` is honest on arrays (blocks index assignment, `push`, + * `length`); on `Set` it is a no-op against `.add`/`.delete`. The + * `as const` annotation pins the literal types. Co-located with + * {@link METRICS_SOFT_SAMPLE_CAP} as the canonical PII surface: adding a + * label here is the single, explicit PR action that admits it into the + * exposition. O(1) membership goes through + * {@link isMetricsAllowedLabelName}. + */ +export const METRICS_ALLOWED_LABEL_NAMES = Object.freeze([ + 'availability', + 'connector_id', + 'connector_type', + 'current_out_type', + 'error_code', + 'evse_id', + 'firmware_version', + 'hash_id', + 'model', + 'ocpp_version', + 'status', + 'template', + 'vendor', + 'version', +] as const) + +/** Label name admitted by {@link METRICS_ALLOWED_LABEL_NAMES}. */ +export type MetricsAllowedLabelName = (typeof METRICS_ALLOWED_LABEL_NAMES)[number] + +const metricsAllowedLabelNameSet: ReadonlySet = new Set( + METRICS_ALLOWED_LABEL_NAMES +) + +/** + * O(1) type-guard predicate over {@link METRICS_ALLOWED_LABEL_NAMES}. + * @param name Candidate label name. + * @returns `true` iff `name` is one of the admitted label names. + */ +export const isMetricsAllowedLabelName = (name: string): name is MetricsAllowedLabelName => + (metricsAllowedLabelNameSet as ReadonlySet).has(name) + +/** + * URL pathname under which Prometheus exposition is served on every UI + * transport (`http`, `ws`, `mcp`). The endpoint is opt-in via + * `uiServer.metrics.enabled`; the pathname is not configurable. Internal + * constant — not part of the public API. + */ +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 AbstractUIServer.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[] +} + export abstract class AbstractUIServer { protected readonly httpServer: Http2Server | Server protected readonly rateLimiter: ReturnType @@ -81,6 +163,31 @@ export abstract class AbstractUIServer { private readonly chargingStations: Map private readonly chargingStationTemplates: Set private clientNotificationDebounceTimer: ReturnType | undefined + private metricsRegistry?: Registry + /** + * Per-scrape sample counter. Reset to 0 at the start of each scrape and + * snapshotted into a scrape-local `sampleCount` BEFORE the first `await` + * (see {@link runMetricsScrape}). Mutated by the `accountSamples` + * closure captured by every `collect()` callback in {@link buildMetricsRegistry}. + * Concurrency safety has two layers: + * 1. Within a `start()` cycle, {@link metricsScrapeChain} serializes + * scrapes so no two `reset → Registry.metrics()` interleave their + * counter mutations. + * 2. Across `stop()`/`start()` cycles, where {@link start} reseats the + * chain to `Promise.resolve()`, the pre-yield local capture in + * {@link runMetricsScrape} frees the soft-cap branch from + * cross-cycle reads of this field. + * Both layers also require the `prom-client` 15.x sync prefix to remain + * yield-free: `Registry.metrics()` iterates metrics and invokes each + * `Gauge.get()` (driving its `collect()` callback) synchronously before + * its first `await`. Every `collect()` callback must therefore be + * synchronous, and a future `prom-client` release that adds a pre-map + * `await` (lazy hooks, OTEL bridge, async default-label resolution) + * would invalidate the pre-yield capture and require revisiting layer 2. + */ + private metricsSampleCount = 0 + private metricsScrapeChain: Promise = Promise.resolve() + private transportAttached = false public constructor ( protected readonly uiServerConfiguration: UIServerConfiguration, @@ -218,19 +325,129 @@ export abstract class AbstractUIServer { } } - public abstract start (): void + /** + * Bring the UI server up exactly once. Template-method that builds the + * Prometheus registry (gated by `uiServer.metrics.enabled`), invokes + * the subclass {@link attachTransport} hook, then starts the shared + * HTTP server. The ordering — registry BEFORE listener BEFORE + * `listen()` — combined with the one-shot {@link transportAttached} + * guard guarantees no caller can reorder, skip, or re-run transport + * attachment. The guard is set BEFORE {@link attachTransport} so a + * throwing hook cannot leave a half-attached server that admits a + * silent retry; on failure any partially-registered listeners on + * `httpServer` are stripped, the guard is rolled back, and the throw + * propagates. A second `start()` without an intervening `stop()` + * throws {@link BaseError}; recovery requires the explicit + * `stop()` → `start()` cycle. + */ + public start (): void { + if (this.transportAttached) { + throw new BaseError( + `${this.uiServerType} start() invoked twice; attachTransport() is one-shot` + ) + } + // Fresh lifecycle: drop the chain accreted across prior stop() cycles + // so captured-registry closures are not retained for the lifetime of + // the server instance. The prior chain's scheduled registry.clear() + // still runs against its own captured registry reference. + this.metricsScrapeChain = Promise.resolve() + this.buildMetricsRegistryIfEnabled() + this.transportAttached = true + try { + this.attachTransport() + } catch (error) { + try { + this.detachTransport() + } catch (detachError) { + logger.error( + `${this.logPrefix(moduleName, 'start')} Error during detachTransport() on attach failure, continuing rollback:`, + detachError + ) + } + this.httpServer.removeAllListeners('request') + this.httpServer.removeAllListeners('upgrade') + this.metricsRegistry = undefined + this.transportAttached = false + throw error + } + this.startHttpServer() + } + /** + * Bring the UI server down and release any Prometheus registry held on + * this instance. Two independent orderings coexist: + * + * 1. The registry clear is **scheduled** onto + * `metricsScrapeChain.finally` so any in-flight scrape finishes + * BEFORE `registry.clear()` runs. The terminal + * `.catch(() => undefined)` keeps the chain field pointing at a + * handled promise so a late rejection cannot escape. + * 2. The subclass {@link detachTransport} hook and the shared HTTP + * server shutdown run **synchronously** in body order (detach → + * close → stop services → clear handlers/caches → reset + * {@link transportAttached}). They do NOT wait for the scheduled + * registry clear; subclass overrides MUST NOT touch state read by + * `collect()` callbacks still in the scheduled clear chain. + * + * A throw from {@link detachTransport} or from a single + * `uiService.stop()` is caught and logged so downstream teardown + * (`stopHttpServer`, remaining services, handlers, caches) still runs. + * Any other unexpected throw inside the teardown body still + * propagates, but {@link transportAttached} is reset in a `finally` + * block so a subsequent `start()` is never permanently locked out. + */ public stop (): void { - clearTimeout(this.clientNotificationDebounceTimer) - this.stopHttpServer() - for (const uiService of this.uiServices.values()) { - uiService.stop() + try { + clearTimeout(this.clientNotificationDebounceTimer) + this.clientNotificationDebounceTimer = undefined + if (this.metricsRegistry !== undefined) { + const registry = this.metricsRegistry + this.metricsScrapeChain = this.metricsScrapeChain + .finally(() => { + registry.clear() + }) + .catch(() => undefined) + this.metricsRegistry = undefined + } + // detachTransport() / uiService.stop() are subclass hooks — see + // the "throw from detachTransport()" paragraph in the JSDoc above + // for the catch-and-log rationale. + try { + this.detachTransport() + } catch (error) { + logger.error( + `${this.logPrefix(moduleName, 'stop')} Error during detachTransport(), continuing teardown:`, + error + ) + } + this.stopHttpServer() + for (const uiService of this.uiServices.values()) { + try { + uiService.stop() + } catch (error) { + logger.error( + `${this.logPrefix(moduleName, 'stop')} Error during uiService.stop(), continuing teardown:`, + error + ) + } + } + this.uiServices.clear() + this.responseHandlers.clear() + this.clearCaches() + } finally { + this.transportAttached = false } - this.uiServices.clear() - this.responseHandlers.clear() - this.clearCaches() } + /** + * Attach the transport's request and/or upgrade listener(s) on + * `this.httpServer`. Implementations MUST NOT invoke + * `httpServer.listen()` — the template {@link start} owns lifecycle + * ordering. Called exactly once per `start()` cycle; the inverse hook + * is {@link detachTransport}. + */ + protected abstract attachTransport (): void + protected authenticate (req: IncomingMessage): boolean { if (this.uiServerConfiguration.authentication?.enabled !== true) { return true @@ -244,6 +461,57 @@ export abstract class AbstractUIServer { return false } + /** + * Build and memoize the Prometheus {@link Registry} on the first + * `start()` when `uiServer.metrics.enabled === true`. Idempotent: a + * second call after the registry has been built is a no-op. The + * registry is released by {@link stop} via `metricsScrapeChain.finally`. + * The `/metrics` route itself is mounted by {@link tryServeMetrics} from + * each transport listener; this method owns the registry only. + */ + protected buildMetricsRegistryIfEnabled (): void { + if (this.uiServerConfiguration.metrics?.enabled !== true) { + return + } + if (this.metricsRegistry !== undefined) { + return + } + this.metricsRegistry = this.buildMetricsRegistry() + } + + /** + * Destroy the underlying HTTP/1.1 socket after a denial when the + * client has not finished writing. "Pending" = HTTP/1.1 request whose + * body the client has not finished sending (`req.complete === false`). + * HTTP/2 streams self-close via `res.end()`; calling `req.destroy()` + * on an HTTP/2 stream is redundant and may raise + * `ERR_HTTP2_INVALID_STREAM` on some Node versions. The `req.complete` + * guard skips teardown once the client has finished writing (the + * socket is owned by the keep-alive pool). + * @param req Incoming HTTP request. + */ + protected destroyHttp1SocketIfPending (req: IncomingMessage): void { + if (req.httpVersionMajor >= 2) { + return + } + if (!req.complete) { + req.destroy() + } + } + + /** + * Symmetric inverse of {@link attachTransport}. Default no-op because + * `httpServer.removeAllListeners()` in `stopHttpServer` already strips + * every listener installed on the shared server. Subclasses with + * transport-specific resources outliving the HTTP listener override + * to release them. Invoked from {@link stop} BEFORE `stopHttpServer()`; + * see {@link stop} point 2 for the constraint on overrides w.r.t. the + * scheduled registry clear chain. + */ + protected detachTransport (): void { + /* Default: no resources to release */ + } + /** * Connection-close header to attach on denial responses. * @@ -259,6 +527,17 @@ export abstract class AbstractUIServer { : { Connection: 'close' } } + /** + * Accessor for the lazily-built Prometheus {@link Registry}. Returns + * `undefined` when `metrics.enabled !== true` or before + * {@link buildMetricsRegistryIfEnabled} has run. Subclasses MUST NOT + * mutate the registry; lifecycle is owned by {@link stop}. + * @returns The active metrics registry, or `undefined` when disabled. + */ + protected getMetricsRegistry (): Registry | undefined { + return this.metricsRegistry + } + protected getUnauthorizedDenial (): { headers: Readonly> reasonPhrase: string @@ -271,6 +550,59 @@ export abstract class AbstractUIServer { } } + /** + * Render the Prometheus exposition for a `/metrics` GET or HEAD request. + * + * Schedules the scrape on the serial {@link runMetricsScrape} chain and + * converts any rejection into `HTTP 500 Internal Server Error` (only + * when the response is still writable — a partial write is left + * untouched). + * + * Pre-conditions enforced by {@link tryServeMetrics}: `metrics.enabled` + * is true, the request method is `GET` or `HEAD`, the pathname is + * `/metrics`, and the prologue + authentication have already passed. + * + * The `registry === undefined` branch is a defensive 404 against a late + * {@link stop} that nulled the registry while this request was in + * flight. HEAD responses send identical headers to GET (including + * `Content-Length`) with an empty body per RFC 9110 §9.3.2. + * @param req HTTP request (used to detect HEAD). + * @param res Server response to end with the exposition body (or empty + * for HEAD / on error). + */ + protected handleMetricsHttpRequest (req: IncomingMessage, res: ServerResponse): void { + const registry = this.metricsRegistry + if (registry === undefined) { + this.renderDenial(res, { + reasonPhrase: getReasonPhrase(StatusCodes.NOT_FOUND), + status: StatusCodes.NOT_FOUND, + }) + return + } + this.runMetricsScrape(req, res, registry).catch((error: unknown) => { + logger.error( + `${this.logPrefix(moduleName, 'handleMetricsHttpRequest')} Metrics handler error:`, + error + ) + if (!res.headersSent && !res.writableEnded) { + res.writeHead(StatusCodes.INTERNAL_SERVER_ERROR, { 'Content-Type': 'text/plain' }).end() + } + }) + } + + protected 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 + } + } + protected notifyClients (): void { // No-op by default — subclasses with push capability override this } @@ -299,6 +631,24 @@ export abstract class AbstractUIServer { .end(`${payload.status.toString()} ${payload.reasonPhrase}`) } + /** + * Emit a 404 denial then tear down the request socket if the client + * did not finish writing. Used by transports that mount `/metrics` + * next to a single non-default route — every other path is a 404. + * Ordering is fixed: response is rendered first, then `req.destroy()`, + * so observers of `'finish'` see the response before the socket + * teardown. + * @param req Incoming HTTP request. + * @param res Server response. + */ + protected renderNotFoundAndDestroy (req: IncomingMessage, res: ServerResponse): void { + this.renderDenial(res, { + reasonPhrase: getReasonPhrase(StatusCodes.NOT_FOUND), + status: StatusCodes.NOT_FOUND, + }) + this.destroyHttp1SocketIfPending(req) + } + /** * Run the access-policy + rate-limit prologue for a request. * @@ -356,6 +706,484 @@ export abstract class AbstractUIServer { } } + /** + * Single-gate dispatcher for the Prometheus `/metrics` endpoint, shared + * by every transport that mounts an HTTP request listener. Encapsulates + * the `metrics.enabled` gate, the path predicate + * ({@link isMetricsRequest}), authentication, and delegation to + * {@link handleMetricsHttpRequest}. All concrete UI servers MUST call + * this between {@link runRequestPrologue} and their transport-specific + * dispatch so gating, ordering, and auth coverage stay identical across + * transports. + * @param req Incoming HTTP request (post-prologue). + * @param res Server response. + * @returns `true` when the helper has written a response (served or + * denied); the caller MUST then return. `false` when the request is + * not for `/metrics` and the caller should continue its own routing. + */ + protected tryServeMetrics (req: IncomingMessage, res: ServerResponse): boolean { + if (this.uiServerConfiguration.metrics?.enabled !== true) { + return false + } + if (!this.isMetricsRequest(req)) { + return false + } + if (!this.authenticate(req)) { + this.renderDenial(res, this.getUnauthorizedDenial()) + return true + } + this.handleMetricsHttpRequest(req, res) + return true + } + + /** + * 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', + }) + + 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 + } + private isBasicAuthEnabled (): boolean { return ( this.uiServerConfiguration.authentication?.enabled === true && @@ -407,10 +1235,72 @@ export abstract class AbstractUIServer { ) } + /** + * 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 caller's `.catch()` + * converts to HTTP 500. The exposition body is omitted on HEAD per + * RFC 9110 §9.3.2; HEAD response headers are identical to GET, including + * `Content-Length` set to the byte length of the body GET would emit. + * @param req HTTP request (used to detect HEAD). + * @param res HTTP response to end with the exposition body. + * @param registry Source Prometheus registry. + * @returns The chained scrape promise. + */ + private runMetricsScrape ( + req: IncomingMessage, + res: ServerResponse, + registry: Registry + ): Promise { + this.metricsScrapeChain = this.metricsScrapeChain + .catch(() => undefined) + .then(async () => { + this.metricsSampleCount = 0 + // prom-client's sync prefix runs every collect() (each writing + // this.metricsSampleCount via accountSamples) before its first + // internal await. Snapshot the counter here, BEFORE our own + // await yields, so a peer scrape dispatched after a sync + // stop()→start() (which reseats metricsScrapeChain) cannot + // overwrite the count we read for the soft-cap branch. See the + // metricsSampleCount field JSDoc for the full invariant. + const metricsPromise = registry.metrics() + const sampleCount = this.metricsSampleCount + const rawBody: string = await metricsPromise + const cap = this.uiServerConfiguration.metrics?.softSampleCap ?? METRICS_SOFT_SAMPLE_CAP + if (sampleCount > cap) { + logger.warn( + `${this.logPrefix(moduleName, 'runMetricsScrape')} ` + + `${METRICS_SOFT_CAP_WARN_PREFIX} ${sampleCount.toString()} samples ` + + `(soft cap ${cap.toString()})` + ) + } + if (!res.headersSent && !res.writableEnded) { + const contentLength = Buffer.byteLength(rawBody, 'utf8').toString() + const isHead = req.method === HttpMethod.HEAD + res + .writeHead(StatusCodes.OK, { + 'Content-Length': contentLength, + 'Content-Type': registry.contentType, + }) + .end(isHead ? undefined : rawBody) + } + return undefined + }) + return this.metricsScrapeChain + } + + /** + * Strips listeners before `httpServer.close()`. The order releases + * subscribers even if `close()` throws, and lets {@link detachTransport} + * stay a default no-op. Subclasses MUST NOT attach `httpServer` listeners + * whose side-effects must run after shutdown. + */ private stopHttpServer (): void { if (this.httpServer.listening) { - this.httpServer.close() this.httpServer.removeAllListeners() + this.httpServer.close() } } @@ -423,20 +1313,6 @@ 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( @@ -456,3 +1332,253 @@ export abstract class 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/UIHttpServer.ts b/src/charging-station/ui-server/UIHttpServer.ts index c62fc9be..ff6dda0d 100644 --- a/src/charging-station/ui-server/UIHttpServer.ts +++ b/src/charging-station/ui-server/UIHttpServer.ts @@ -2,9 +2,7 @@ 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' @@ -33,30 +31,6 @@ 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. */ @@ -64,19 +38,6 @@ 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, @@ -137,528 +98,11 @@ export class UIHttpServer extends AbstractUIServer { } /** - * @deprecated Use UIMCPServer (ApplicationProtocol.MCP) instead. Will be removed in a future major version. + * @deprecated Use UIMCPServer (ApplicationProtocol.MCP) instead. Will be + * removed in a future major version. */ - public start (): void { + protected attachTransport (): 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 ( @@ -697,41 +141,17 @@ 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) { this.renderDenial(res, prologue) return } - if (!this.authenticate(req)) { - this.renderDenial(res, this.getUnauthorizedDenial()) + if (this.tryServeMetrics(req, res)) { 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() - } - }) + if (!this.authenticate(req)) { + this.renderDenial(res, this.getUnauthorizedDenial()) return } @@ -812,253 +232,3 @@ 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/UIMCPServer.ts b/src/charging-station/ui-server/UIMCPServer.ts index fd302a43..9895d108 100644 --- a/src/charging-station/ui-server/UIMCPServer.ts +++ b/src/charging-station/ui-server/UIMCPServer.ts @@ -109,7 +109,16 @@ export class UIMCPServer extends AbstractUIServer { } } - public start (): void { + public override stop (): void { + for (const [uuid, pending] of [...this.pendingMcpRequests]) { + clearTimeout(pending.timeout) + this.pendingMcpRequests.delete(uuid) + pending.reject(new BaseError('Server stopping')) + } + super.stop() + } + + protected attachTransport (): void { const version = ProtocolVersion['0.0.1'] this.registerProtocolVersionUIService(version) this.service = this.uiServices.get(version) @@ -121,18 +130,12 @@ export class UIMCPServer extends AbstractUIServer { this.renderDenial(res, prologue) return } - + if (this.tryServeMetrics(req, res)) { + return + } const url = new URL(req.url ?? '/', 'http://localhost') - // Path filter runs before authenticate so unknown paths return 404 - // without revealing whether authentication would have succeeded. if (url.pathname !== '/mcp') { - this.renderDenial(res, { - reasonPhrase: getReasonPhrase(StatusCodes.NOT_FOUND), - status: StatusCodes.NOT_FOUND, - }) - if (!req.complete) { - req.destroy() - } + this.renderNotFoundAndDestroy(req, res) return } @@ -148,17 +151,6 @@ export class UIMCPServer extends AbstractUIServer { ) }) }) - - this.startHttpServer() - } - - public override stop (): void { - for (const [uuid, pending] of [...this.pendingMcpRequests]) { - clearTimeout(pending.timeout) - this.pendingMcpRequests.delete(uuid) - pending.reject(new BaseError('Server stopping')) - } - super.stop() } protected getSchemaBaseDir (): string { diff --git a/src/charging-station/ui-server/UIWebSocketServer.ts b/src/charging-station/ui-server/UIWebSocketServer.ts index 2130e4f9..be82f942 100644 --- a/src/charging-station/ui-server/UIWebSocketServer.ts +++ b/src/charging-station/ui-server/UIWebSocketServer.ts @@ -1,4 +1,4 @@ -import type { IncomingMessage } from 'node:http' +import type { IncomingMessage, ServerResponse } from 'node:http' import type { Duplex } from 'node:stream' import { getReasonPhrase, StatusCodes } from 'http-status-codes' @@ -124,7 +124,7 @@ export class UIWebSocketServer extends AbstractUIServer { } } - public start (): void { + protected attachTransport (): void { this.webSocketServer.on('connection', (ws: WebSocket, _req: IncomingMessage): void => { const protocol = ws.protocol const protocolAndVersion = getProtocolAndVersion(protocol) @@ -182,6 +182,17 @@ export class UIWebSocketServer extends AbstractUIServer { } }) }) + this.httpServer.on('request', (req: IncomingMessage, res: ServerResponse): void => { + const prologue = this.runRequestPrologue(req) + if (!prologue.ok) { + this.renderDenial(res, prologue) + return + } + if (this.tryServeMetrics(req, res)) { + return + } + this.renderNotFoundAndDestroy(req, res) + }) this.httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer): void => { const onSocketError = (error: Error): void => { logger.error( @@ -249,7 +260,17 @@ export class UIWebSocketServer extends AbstractUIServer { ) } }) - this.startHttpServer() + } + + /** + * Symmetric inverse of {@link attachTransport}. The `connection` listener + * is registered on the constructor-scoped `webSocketServer` emitter, which + * outlives `httpServer` and is therefore NOT swept by `stopHttpServer()`'s + * `removeAllListeners()`. Strip it here so a `stop()` → `start()` cycle + * does not accumulate duplicate dispatchers. + */ + protected override detachTransport (): void { + this.webSocketServer.removeAllListeners('connection') } protected override notifyClients (): void { diff --git a/src/utils/ConfigurationSchema.ts b/src/utils/ConfigurationSchema.ts index 919ca3a4..c24625e1 100644 --- a/src/utils/ConfigurationSchema.ts +++ b/src/utils/ConfigurationSchema.ts @@ -214,8 +214,11 @@ const UIServerListenOptionsSchema = z /** * 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). + * served by every UI transport (`http`, `ws`, `mcp`). On `ws` and `mcp` + * the endpoint co-mounts on the same listener as the primary transport + * (the underlying `Http2Server | Server` allocated by `AbstractUIServer`) + * and inherits its `accessPolicy`, rate-limit, and `authentication` + * (issue #1917). * * `softSampleCap` (optional, default `METRICS_SOFT_SAMPLE_CAP` = 5000) * is the soft cardinality cap above which a single `logger.warn` is diff --git a/tests/charging-station/ui-server/UIHttpServer.test.ts b/tests/charging-station/ui-server/UIHttpServer.test.ts index 22dca105..11718991 100644 --- a/tests/charging-station/ui-server/UIHttpServer.test.ts +++ b/tests/charging-station/ui-server/UIHttpServer.test.ts @@ -224,7 +224,6 @@ await describe('UIHttpServer', async () => { const res = new MockServerResponse() try { - // eslint-disable-next-line @typescript-eslint/no-deprecated gatedServer.start() httpServer.emit('request', req, res) } finally { @@ -269,7 +268,6 @@ await describe('UIHttpServer', async () => { const res = new MockServerResponse() try { - // eslint-disable-next-line @typescript-eslint/no-deprecated gatedServer.start() httpServer.emit('request', denyingReq, res) } finally { diff --git a/tests/charging-station/ui-server/UIMCPServer.test.ts b/tests/charging-station/ui-server/UIMCPServer.test.ts index df26b7a4..bc3adb8c 100644 --- a/tests/charging-station/ui-server/UIMCPServer.test.ts +++ b/tests/charging-station/ui-server/UIMCPServer.test.ts @@ -10,13 +10,14 @@ import { StatusCodes } from 'http-status-codes' import assert from 'node:assert/strict' import { dirname, join } from 'node:path' import { Readable } from 'node:stream' -import { afterEach, beforeEach, describe, it } from 'node:test' +import { afterEach, beforeEach, describe, it, type mock } from 'node:test' import { fileURLToPath } from 'node:url' import type { ProtocolResponse, RequestPayload, ResponsePayload, + TemplateStatistics, UIServerConfiguration, } from '../../../src/types/index.js' @@ -32,6 +33,7 @@ import { } from '../../../src/charging-station/ui-server/UIServerSecurity.js' import { ApplicationProtocol, + AuthenticationType, OCPPVersion, ProcedureName, ResponseStatus, @@ -44,11 +46,14 @@ import { } from '../../helpers/TestLifecycleHelpers.js' import { TEST_HASH_ID, TEST_HASH_ID_2, TEST_UUID, TEST_UUID_2 } from './UIServerTestConstants.js' import { + awaitFinish, createMockBootstrap, createMockChargingStationDataWithVersion, createMockIncomingMessage, createMockUIServerConfiguration, createMockUIServerConfigurationWithAuth, + drainResponses, + extractGaugeValue, MockServerResponse, } from './UIServerTestUtils.js' @@ -120,6 +125,13 @@ class TestableUIMCPServer extends UIMCPServer { ).call(this, res, statusCode, headers) } + public emitRequest (req: IncomingMessage, res: MockServerResponse): void { + const httpServer = Reflect.get(this, 'httpServer') as { + emit: (eventName: string, req: IncomingMessage, res: MockServerResponse) => boolean + } + httpServer.emit('request', req, res) + } + public getPendingMcpRequest (uuid: string): | undefined | { @@ -154,6 +166,11 @@ class TestableUIMCPServer extends UIMCPServer { return (Reflect.get(this, 'pendingMcpRequests') as Map).size } + 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) + } + protected override getSchemaBaseDir (): string { return join( dirname(fileURLToPath(import.meta.url)), @@ -171,6 +188,43 @@ class TestableUIMCPServer extends UIMCPServer { const createMcpServerConfig = () => createMockUIServerConfiguration({ type: ApplicationProtocol.MCP }) +const createMcpMetricsConfig = ( + overrides: Partial = {} +): UIServerConfiguration => + createMockUIServerConfiguration({ + metrics: { enabled: true }, + options: { host: '127.0.0.1', port: 0 }, + type: ApplicationProtocol.MCP, + ...overrides, + }) + +const buildMcpMetricsRequest = (overrides: Partial = {}): IncomingMessage => + createMockIncomingMessage({ + complete: true, + headers: { host: 'localhost' }, + method: 'GET', + socket: { encrypted: false, remoteAddress: '127.0.0.1' } as never, + url: '/metrics', + ...overrides, + }) + +const enrichBootstrapForMetrics = (server: TestableUIMCPServer, 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, + })) +} + /** * Assert that a CallToolResult is an error containing the expected substring. * @param result - MCP tool result to validate @@ -932,4 +986,300 @@ await describe('UIMCPServer', async () => { } }) }) + + await describe('metrics endpoint when uiServer.type=mcp (issue #1917)', async () => { + await it('should serve Prometheus exposition on GET /metrics when enabled', async t => { + const mcpServer = new TestableUIMCPServer(createMcpMetricsConfig()) + enrichBootstrapForMetrics(mcpServer) + mcpServer.mockListen(t) + try { + mcpServer.start() + const res = new MockServerResponse() + mcpServer.emitRequest(buildMcpMetricsRequest(), res) + await awaitFinish(res) + assert.strictEqual(res.statusCode, 200) + assert.match(res.headers['Content-Type'] ?? '', /^text\/plain;\s*version=0\.0\.4/) + assert.strictEqual( + extractGaugeValue(res.body ?? '', 'simulator_started'), + 1, + 'simulator_started must be 1 from the enrichBootstrapForMetrics fixture' + ) + assert.match(res.body ?? '', /^# HELP simulator_started /m) + assert.match(res.body ?? '', /^# TYPE simulator_started gauge$/m) + } finally { + mcpServer.stop() + } + }) + + await it('should return 404 on GET /metrics when metrics.enabled=false (default)', t => { + const mcpServer = new TestableUIMCPServer( + createMockUIServerConfiguration({ + options: { host: '127.0.0.1', port: 0 }, + type: ApplicationProtocol.MCP, + }) + ) + enrichBootstrapForMetrics(mcpServer) + mcpServer.mockListen(t) + try { + mcpServer.start() + const res = new MockServerResponse() + mcpServer.emitRequest(buildMcpMetricsRequest(), res) + assert.strictEqual(res.statusCode, 404) + } finally { + mcpServer.stop() + } + }) + + await it('should return 404 on POST /metrics (transport-method parity with HTTP/WS)', t => { + const mcpServer = new TestableUIMCPServer(createMcpMetricsConfig()) + enrichBootstrapForMetrics(mcpServer) + mcpServer.mockListen(t) + try { + mcpServer.start() + const res = new MockServerResponse() + mcpServer.emitRequest(buildMcpMetricsRequest({ method: 'POST' }), res) + assert.strictEqual(res.statusCode, 404) + } finally { + mcpServer.stop() + } + }) + + await it('should still return 404 on unknown paths after /metrics is enabled', t => { + const mcpServer = new TestableUIMCPServer(createMcpMetricsConfig()) + enrichBootstrapForMetrics(mcpServer) + mcpServer.mockListen(t) + try { + mcpServer.start() + const res = new MockServerResponse() + mcpServer.emitRequest( + buildMcpMetricsRequest({ + method: 'GET', + url: '/unknown', + }), + res + ) + assert.strictEqual(res.statusCode, 404) + } finally { + mcpServer.stop() + } + }) + + await it('should inherit AccessPolicy denial — 403 on non-loopback without TLS', t => { + const mcpServer = new TestableUIMCPServer( + createMcpMetricsConfig({ + accessPolicy: { + allowedHosts: ['gateway.example.com'], + allowedOrigins: [], + allowLoopbackProxy: false, + requireTlsForNonLoopback: true, + trustedProxies: [], + }, + }) + ) + enrichBootstrapForMetrics(mcpServer) + mcpServer.mockListen(t) + try { + mcpServer.start() + const res = new MockServerResponse() + mcpServer.emitRequest( + buildMcpMetricsRequest({ + headers: { host: 'gateway.example.com' }, + socket: { encrypted: false, remoteAddress: '203.0.113.10' } as never, + }), + res + ) + assert.strictEqual(res.statusCode, 403) + } finally { + mcpServer.stop() + } + }) + + await it('should return 401 without credentials when authentication is enabled', t => { + const mcpServer = new TestableUIMCPServer( + createMcpMetricsConfig({ + authentication: { + enabled: true, + password: 'pw', + type: AuthenticationType.BASIC_AUTH, + username: 'user', + }, + }) + ) + enrichBootstrapForMetrics(mcpServer) + mcpServer.mockListen(t) + try { + mcpServer.start() + const res = new MockServerResponse() + mcpServer.emitRequest(buildMcpMetricsRequest(), res) + assert.strictEqual(res.statusCode, 401) + assert.strictEqual(res.headers['WWW-Authenticate'], 'Basic realm=users') + } finally { + mcpServer.stop() + } + }) + + await it('should return 200 with valid Basic Auth credentials', async t => { + const mcpServer = new TestableUIMCPServer( + createMcpMetricsConfig({ + authentication: { + enabled: true, + password: 'pw', + type: AuthenticationType.BASIC_AUTH, + username: 'user', + }, + }) + ) + enrichBootstrapForMetrics(mcpServer) + mcpServer.mockListen(t) + try { + mcpServer.start() + const credentials = Buffer.from('user:pw').toString('base64') + const res = new MockServerResponse() + mcpServer.emitRequest( + buildMcpMetricsRequest({ + headers: { authorization: `Basic ${credentials}`, host: 'localhost' }, + }), + res + ) + await awaitFinish(res) + assert.strictEqual(res.statusCode, 200) + } finally { + mcpServer.stop() + } + }) + + await it('should return 404 (not 401) on GET /metrics when metrics.enabled=false and authentication.enabled=true', t => { + const mcpServer = new TestableUIMCPServer( + createMockUIServerConfiguration({ + authentication: { + enabled: true, + password: 'pw', + type: AuthenticationType.BASIC_AUTH, + username: 'user', + }, + options: { host: '127.0.0.1', port: 0 }, + type: ApplicationProtocol.MCP, + }) + ) + enrichBootstrapForMetrics(mcpServer) + mcpServer.mockListen(t) + try { + mcpServer.start() + const res = new MockServerResponse() + mcpServer.emitRequest(buildMcpMetricsRequest(), res) + assert.strictEqual(res.statusCode, 404) + assert.strictEqual(res.headers['WWW-Authenticate'], undefined) + } finally { + mcpServer.stop() + } + }) + + await it('should respond 200 with empty body on HEAD /metrics per RFC 9110 §9.3.2', async t => { + const mcpServer = new TestableUIMCPServer(createMcpMetricsConfig()) + enrichBootstrapForMetrics(mcpServer) + mcpServer.mockListen(t) + try { + mcpServer.start() + const res = new MockServerResponse() + mcpServer.emitRequest(buildMcpMetricsRequest({ method: 'HEAD' }), res) + await awaitFinish(res) + assert.strictEqual(res.statusCode, 200) + assert.match(res.headers['Content-Type'] ?? '', /^text\/plain;\s*version=0\.0\.4/) + assert.strictEqual(res.body, undefined, 'HEAD response body must be empty') + } finally { + mcpServer.stop() + } + }) + + await it('should emit explicit Content-Length on both HEAD and GET /metrics (RFC 9110 §9.3.2 parity)', async t => { + const mcpServer = new TestableUIMCPServer(createMcpMetricsConfig()) + enrichBootstrapForMetrics(mcpServer) + mcpServer.mockListen(t) + try { + mcpServer.start() + const getRes = new MockServerResponse() + const headRes = new MockServerResponse() + mcpServer.emitRequest(buildMcpMetricsRequest(), getRes) + mcpServer.emitRequest(buildMcpMetricsRequest({ method: 'HEAD' }), headRes) + await drainResponses([getRes, headRes]) + const getLen = Number(getRes.headers['Content-Length']) + const headLen = Number(headRes.headers['Content-Length']) + assert.strictEqual( + getLen, + Buffer.byteLength(getRes.body ?? '', 'utf8'), + 'GET Content-Length must equal body byte length' + ) + assert.strictEqual( + headLen, + getLen, + 'HEAD Content-Length must equal GET length (RFC 9110 §9.3.2)' + ) + assert.strictEqual(headRes.body, undefined, 'HEAD body must be empty') + } finally { + mcpServer.stop() + } + }) + + await it('should NOT call req.destroy() on the 404 fallback when req.httpVersionMajor >= 2 (HTTP/2 stream lifecycle)', t => { + const mcpServer = new TestableUIMCPServer(createMcpMetricsConfig()) + enrichBootstrapForMetrics(mcpServer) + mcpServer.mockListen(t) + try { + mcpServer.start() + let destroyCount = 0 + const req = buildMcpMetricsRequest({ complete: false, url: '/unknown' }) + ;( + req as IncomingMessage & { + destroy: () => IncomingMessage + httpVersionMajor: number + } + ).destroy = () => { + destroyCount++ + return req + } + ;(req as IncomingMessage & { httpVersionMajor: number }).httpVersionMajor = 2 + const res = new MockServerResponse() + mcpServer.emitRequest(req, res) + assert.strictEqual(res.statusCode, 404, 'response still rendered first') + assert.strictEqual( + destroyCount, + 0, + 'HTTP/2 streams must not be destroyed by renderNotFoundAndDestroy (stream lifecycle is owned by the Http2Stream)' + ) + } finally { + mcpServer.stop() + } + }) + + await it('should NOT call req.destroy() on the 404 fallback when req.httpVersionMajor === 3 (HTTP/3 future-proofing)', t => { + const mcpServer = new TestableUIMCPServer(createMcpMetricsConfig()) + enrichBootstrapForMetrics(mcpServer) + mcpServer.mockListen(t) + try { + mcpServer.start() + let destroyCount = 0 + const req = buildMcpMetricsRequest({ complete: false, url: '/unknown' }) + ;( + req as IncomingMessage & { + destroy: () => IncomingMessage + httpVersionMajor: number + } + ).destroy = () => { + destroyCount++ + return req + } + ;(req as IncomingMessage & { httpVersionMajor: number }).httpVersionMajor = 3 + const res = new MockServerResponse() + mcpServer.emitRequest(req, res) + assert.strictEqual(res.statusCode, 404) + assert.strictEqual( + destroyCount, + 0, + 'destroyHttp1SocketIfPending must short-circuit on httpVersionMajor >= 2 (locks the `>= 2` semantic against a future `=== 2` regression)' + ) + } finally { + mcpServer.stop() + } + }) + }) }) diff --git a/tests/charging-station/ui-server/UIMetricsEndpoint.test.ts b/tests/charging-station/ui-server/UIMetricsEndpoint.test.ts index 34f994e2..1297850a 100644 --- a/tests/charging-station/ui-server/UIMetricsEndpoint.test.ts +++ b/tests/charging-station/ui-server/UIMetricsEndpoint.test.ts @@ -3,8 +3,9 @@ * @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 { IncomingMessage, Server } from 'node:http' import type { mock } from 'node:test' +import type { Registry } from 'prom-client' import assert from 'node:assert/strict' import { once } from 'node:events' @@ -16,12 +17,16 @@ import type { UIServerConfiguration, } from '../../../src/types/index.js' -import { AbstractUIServer } from '../../../src/charging-station/ui-server/AbstractUIServer.js' import { + AbstractUIServer, + isMetricsAllowedLabelName, + METRICS_ALLOWED_LABEL_NAMES, + METRICS_SOFT_CAP_WARN_PREFIX, METRICS_SOFT_SAMPLE_CAP, - UIHttpServer, -} from '../../../src/charging-station/ui-server/UIHttpServer.js' +} from '../../../src/charging-station/ui-server/AbstractUIServer.js' +import { UIHttpServer } from '../../../src/charging-station/ui-server/UIHttpServer.js' import { UIWebSocketServer } from '../../../src/charging-station/ui-server/UIWebSocketServer.js' +import { BaseError } from '../../../src/exception/index.js' import { ApplicationProtocol, AuthenticationType, @@ -58,8 +63,9 @@ class TestableUIHttpServer extends UIHttpServer { httpServer.emit('request', req, res) } - public getMetricsRegistry (): unknown { - return Reflect.get(this, 'metricsRegistry') + public override getMetricsRegistry (): Registry | undefined { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return super.getMetricsRegistry() } public mockListen (t: { mock: { method: typeof mock.method } }): void { @@ -141,6 +147,24 @@ const buildMetricsRequest = (overrides: Partial = {}): Incoming ...overrides, }) +const populateLiveState = ( + server: TestableUIHttpServer +): { uiSvc: { stop: () => void; stopCalled: number } } => { + const uiSvc = { + stop (): void { + this.stopCalled++ + }, + stopCalled: 0, + } + ;(Reflect.get(server, 'uiServices') as Map).set('1.1', uiSvc) + ;(Reflect.get(server, 'responseHandlers') as Map).set('uuid-probe', {}) + ;(Reflect.get(server, 'chargingStations') as Map).set('h-probe', { + hashId: 'h-probe', + }) + ;(Reflect.get(server, 'chargingStationTemplates') as Set).add('tpl-probe') + return { uiSvc } +} + await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { let server: TestableUIHttpServer @@ -156,7 +180,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { 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) @@ -174,7 +198,6 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { enrichBootstrap(plainServer) plainServer.mockListen(t) try { - // eslint-disable-next-line @typescript-eslint/no-deprecated plainServer.start() const res = new MockServerResponse() plainServer.emitRequest(buildMetricsRequest(), res) @@ -194,7 +217,6 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { enrichBootstrap(offServer) offServer.mockListen(t) try { - // eslint-disable-next-line @typescript-eslint/no-deprecated offServer.start() const res = new MockServerResponse() offServer.emitRequest(buildMetricsRequest(), res) @@ -206,7 +228,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { 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) @@ -222,7 +244,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { 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) @@ -236,7 +258,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { 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) @@ -253,7 +275,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { 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) @@ -275,7 +297,6 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { enrichBootstrap(gatedServer) gatedServer.mockListen(t) try { - // eslint-disable-next-line @typescript-eslint/no-deprecated gatedServer.start() const res = new MockServerResponse() gatedServer.emitRequest( @@ -293,7 +314,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { 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++) { @@ -321,7 +342,6 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { enrichBootstrap(authServer) authServer.mockListen(t) try { - // eslint-disable-next-line @typescript-eslint/no-deprecated authServer.start() const res = new MockServerResponse() authServer.emitRequest(buildMetricsRequest(), res) @@ -346,7 +366,6 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { 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() @@ -399,7 +418,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { }) ) server.mockListen(t) - // eslint-disable-next-line @typescript-eslint/no-deprecated + server.start() const res = new MockServerResponse() server.emitRequest(buildMetricsRequest(), res) @@ -439,7 +458,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { }) ) server.mockListen(t) - // eslint-disable-next-line @typescript-eslint/no-deprecated + server.start() const res = new MockServerResponse() server.emitRequest(buildMetricsRequest(), res) @@ -457,7 +476,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { 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) @@ -467,7 +486,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { 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) @@ -482,7 +501,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { } 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) @@ -490,10 +509,10 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { 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') + return typeof message === 'string' && message.includes(METRICS_SOFT_CAP_WARN_PREFIX) }) assert.ok( - matchingCalls.length >= 1, + matchingCalls.length > 0, `Expected at least one logger.warn 'soft cap' call after ${stationCount.toString()} stations; got ${warnSpy.mock.calls.length.toString()} warn calls total` ) }) @@ -501,7 +520,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { 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) @@ -540,7 +559,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { }) ) server.mockListen(t) - // eslint-disable-next-line @typescript-eslint/no-deprecated + server.start() const res = new MockServerResponse() server.emitRequest(buildMetricsRequest(), res) @@ -572,7 +591,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { 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) @@ -593,14 +612,14 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { 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') + return typeof message === 'string' && message.includes(METRICS_SOFT_CAP_WARN_PREFIX) }).length exactServer.stop() assert.strictEqual( @@ -621,14 +640,14 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { 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') + return typeof message === 'string' && message.includes(METRICS_SOFT_CAP_WARN_PREFIX) }).length belowServer.stop() assert.ok( @@ -653,7 +672,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { 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) @@ -673,7 +692,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { concurrentServer.addStation(buildStationData(`station-T20-${i.toString()}`)) } concurrentServer.mockListen(t) - // eslint-disable-next-line @typescript-eslint/no-deprecated + concurrentServer.start() const resA = new MockServerResponse() @@ -701,7 +720,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { ) const softCapCalls = warnSpy.mock.calls.filter(call => { const message: unknown = call.arguments[0] - return typeof message === 'string' && message.includes('soft cap') + return typeof message === 'string' && message.includes(METRICS_SOFT_CAP_WARN_PREFIX) }).length assert.strictEqual( softCapCalls, @@ -710,7 +729,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { ) }) - await it('should fire warnIfMisconfigured when metrics.enabled=true && type=ws', t => { + await it('should not warn about transport-restriction when metrics.enabled=true && type=ws (issue #1917)', t => { const warnSpy = t.mock.method(logger, 'warn', () => undefined) const wsServer = new UIWebSocketServer( createMockUIServerConfiguration({ @@ -724,17 +743,1005 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => { const matchingCalls = warnSpy.mock.calls.filter(call => { const message: unknown = call.arguments[0] return ( - typeof message === 'string' && message.includes('metrics') && message.includes('http') + typeof message === 'string' && + /metrics\.enabled=true/i.test(message) && + /honored only/i.test(message) ) }) - assert.ok( - matchingCalls.length >= 1, - `Expected logger.warn about metrics.enabled on non-HTTP transport; saw ${warnSpy.mock.calls.length.toString()} total` + assert.strictEqual( + matchingCalls.length, + 0, + `Metrics endpoint is now transport-agnostic; transport-restriction warning must not be emitted. Saw ${matchingCalls.length.toString()} matching warn(s).` ) } finally { wsServer.stop() - // satisfy AbstractUIServer reference (no-op outside this scope) void AbstractUIServer } }) + + await it('start() runs buildMetricsRegistryIfEnabled THEN attachTransport THEN httpServer.listen (strict template-method ordering)', t => { + const server = new TestableUIHttpServer(createMetricsConfig()) + const order: string[] = [] + const proto = Reflect.getPrototypeOf(server) as { + attachTransport: () => void + buildMetricsRegistryIfEnabled: () => void + } + const origBuild = proto.buildMetricsRegistryIfEnabled.bind(server) + const origAttach = proto.attachTransport.bind(server) + ;( + server as unknown as { buildMetricsRegistryIfEnabled: () => void } + ).buildMetricsRegistryIfEnabled = () => { + order.push('build') + origBuild() + } + ;(server as unknown as { attachTransport: () => void }).attachTransport = () => { + order.push('attach') + origAttach() + } + const httpServer = Reflect.get(server, 'httpServer') as { + listen: (...args: unknown[]) => unknown + } + t.mock.method( + httpServer as never, + 'listen' as never, + ((): unknown => { + order.push('listen') + return httpServer + }) as never + ) + try { + server.start() + assert.deepStrictEqual( + order, + ['build', 'attach', 'listen'], + 'strict template-method ordering: build → attach → listen' + ) + } finally { + server.stop() + } + }) + + await it('start() is one-shot — a second call throws BaseError and does not re-attach', t => { + const server = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(server) + server.mockListen(t) + try { + server.start() + const registry1 = server.getMetricsRegistry() + const httpServer = Reflect.get(server, 'httpServer') as { + listenerCount: (event: string) => number + } + const listeners1 = httpServer.listenerCount('request') + assert.throws( + () => { + server.start() + }, + (err: unknown) => err instanceof BaseError, + 'second start() must throw BaseError' + ) + assert.strictEqual( + server.getMetricsRegistry(), + registry1, + 'metricsRegistry reference must be preserved after the rejected second start()' + ) + assert.strictEqual( + httpServer.listenerCount('request'), + listeners1, + 'request listeners must not be doubled by the rejected second start()' + ) + } finally { + server.stop() + } + }) + + await it('Content-Length equals UTF-8 byte count of body, not codepoint count (non-ASCII version label)', async t => { + const server = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(server, '1.0.0-✓') + server.mockListen(t) + try { + server.start() + const res = new MockServerResponse() + server.emitRequest(buildMetricsRequest(), res) + await once(res, 'finish') + const body = res.body ?? '' + assert.ok(body.includes('1.0.0-✓'), 'non-ASCII version must propagate into exposition body') + const checkMarkOccurrences = (body.match(/✓/gu) ?? []).length + assert.ok(checkMarkOccurrences >= 1, 'at least one ✓ must appear in the body') + const charLen = body.length + const byteLen = Buffer.byteLength(body, 'utf8') + assert.notStrictEqual(charLen, byteLen, 'non-ASCII body must have byteLen !== charLen') + assert.strictEqual( + byteLen - charLen, + checkMarkOccurrences * 2, + 'each ✓ contributes +2 bytes (U+2713 = 3-byte UTF-8 vs 1 codepoint)' + ) + assert.strictEqual( + Number(res.headers['Content-Length']), + byteLen, + 'Content-Length header must equal UTF-8 byte length, not codepoint count' + ) + } finally { + server.stop() + } + }) + + await it('stop() invokes detachTransport BEFORE stopHttpServer (lifecycle symmetry)', t => { + const server = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(server) + server.mockListen(t) + const order: string[] = [] + const proto = Reflect.getPrototypeOf(server) as { + detachTransport: () => void + stopHttpServer: () => void + } + const origDetach = proto.detachTransport.bind(server) + const origStopHttp = proto.stopHttpServer.bind(server) + ;(server as unknown as { detachTransport: () => void }).detachTransport = (): void => { + order.push('detachTransport') + origDetach() + } + ;(server as unknown as { stopHttpServer: () => void }).stopHttpServer = (): void => { + order.push('stopHttpServer') + origStopHttp() + } + server.start() + server.stop() + assert.deepStrictEqual( + order, + ['detachTransport', 'stopHttpServer'], + 'detachTransport must be called before stopHttpServer for symmetric teardown' + ) + }) + + await it('handleMetricsHttpRequest error path is a no-op when res.writableEnded after the scrape rejected post-end()', async t => { + const server = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(server) + server.mockListen(t) + try { + server.start() + const res = new MockServerResponse() + let writeHeadCount = 0 + const origWriteHead = res.writeHead.bind(res) + ;( + res as MockServerResponse & { + writeHead: (status: number, headers?: Record) => MockServerResponse + } + ).writeHead = (status: number, headers?: Record): MockServerResponse => { + writeHeadCount += 1 + return origWriteHead(status, headers) + } + Reflect.set( + server, + 'runMetricsScrape', + (_req: IncomingMessage, r: MockServerResponse): Promise => { + r.writeHead(200, { 'Content-Type': 'text/plain' }).end('partial') + return Promise.reject(new Error('post-end fail')) + } + ) + server.emitRequest(buildMetricsRequest(), res) + await new Promise(resolve => { + setImmediate(resolve) + }) + await new Promise(resolve => { + setImmediate(resolve) + }) + assert.strictEqual(res.statusCode, 200, 'partial 200 must NOT be rewritten to 500') + assert.strictEqual( + writeHeadCount, + 1, + 'writeHead must run exactly once — no double-write after end()' + ) + assert.strictEqual(res.body, 'partial', 'body must not be overwritten by the error path') + } finally { + server.stop() + } + }) + + await it('stop() removes all httpServer listeners AND clears the registry when httpServer.listening === true', t => { + const server = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(server) + server.mockListen(t) + server.start() + const httpServer = Reflect.get(server, 'httpServer') as { + close: () => unknown + listenerCount: (event: string) => number + listening: boolean + } + t.mock.method(httpServer as never, 'close' as never, ((): unknown => httpServer) as never) + Object.defineProperty(httpServer, 'listening', { configurable: true, value: true }) + const requestListenersBefore = httpServer.listenerCount('request') + assert.ok(requestListenersBefore >= 1, 'precondition: request listener attached') + assert.notStrictEqual(server.getMetricsRegistry(), undefined) + server.stop() + assert.strictEqual( + httpServer.listenerCount('request'), + 0, + 'stopHttpServer must removeAllListeners() when listening was true' + ) + assert.strictEqual( + server.getMetricsRegistry(), + undefined, + 'metricsRegistry reference must be released by stop()' + ) + }) + + await it('exposition body emits only labels listed in METRICS_ALLOWED_LABEL_NAMES (PII guardian)', t => { + const server = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(server) + server.addStation(buildStationData('station-PII')) + server.mockListen(t) + try { + server.start() + const registry = server.getMetricsRegistry() + assert(registry !== undefined, 'precondition: registry must be built') + const metrics = registry.getMetricsAsArray() + const leaked: string[] = [] + for (const metric of metrics) { + const declared = (metric as { labelNames?: readonly string[] }).labelNames ?? [] + for (const labelName of declared) { + if (!isMetricsAllowedLabelName(labelName)) leaked.push(labelName) + } + } + assert.deepStrictEqual( + leaked, + [], + `Label PII allowlist violated. Labels not in METRICS_ALLOWED_LABEL_NAMES: ${leaked.join(', ')}. If intentional, add to METRICS_ALLOWED_LABEL_NAMES with security review.` + ) + } finally { + server.stop() + } + }) + + await it('METRICS_ALLOWED_LABEL_NAMES is a runtime-immutable frozen tuple', () => { + assert.ok( + Object.isFrozen(METRICS_ALLOWED_LABEL_NAMES), + 'METRICS_ALLOWED_LABEL_NAMES must be Object.frozen (honest on arrays, blocks push/length/index)' + ) + assert.throws( + () => { + ;(METRICS_ALLOWED_LABEL_NAMES as unknown as string[]).push('rogue') + }, + TypeError, + 'push on frozen array must throw TypeError at runtime' + ) + assert.strictEqual(METRICS_ALLOWED_LABEL_NAMES.length, 14) + assert.deepStrictEqual( + [...METRICS_ALLOWED_LABEL_NAMES].sort(), + [ + 'availability', + 'connector_id', + 'connector_type', + 'current_out_type', + 'error_code', + 'evse_id', + 'firmware_version', + 'hash_id', + 'model', + 'ocpp_version', + 'status', + 'template', + 'vendor', + 'version', + ].sort() + ) + }) + + await it('METRICS_SOFT_SAMPLE_CAP default is 5000 (operator-facing contract; README-documented)', () => { + assert.strictEqual( + METRICS_SOFT_SAMPLE_CAP, + 5000, + 'METRICS_SOFT_SAMPLE_CAP default must remain 5000 — Prometheus operators rely on the README-documented soft-warn threshold; any change requires a coordinated docs + release-notes update' + ) + }) + + await it('start() → stop() → start() succeeds (lifecycle is repeatable across cycles)', t => { + const server = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(server) + server.mockListen(t) + let attachCalls = 0 + const proto = Reflect.getPrototypeOf(server) as { attachTransport: () => void } + const origAttach = proto.attachTransport.bind(server) + ;(server as unknown as { attachTransport: () => void }).attachTransport = () => { + attachCalls += 1 + origAttach() + } + try { + server.start() + const registry1 = server.getMetricsRegistry() + const httpServer = Reflect.get(server, 'httpServer') as { + close: () => unknown + listenerCount: (event: string) => number + listening: boolean + } + t.mock.method(httpServer as never, 'close' as never, ((): unknown => httpServer) as never) + Object.defineProperty(httpServer, 'listening', { configurable: true, value: true }) + const listenerCount1 = httpServer.listenerCount('request') + server.stop() + assert.doesNotThrow(() => { + server.start() + }, 'second start() after stop() must NOT throw the one-shot BaseError') + assert.strictEqual(attachCalls, 2, 'attachTransport must run on each fresh start()') + assert.notStrictEqual( + server.getMetricsRegistry(), + undefined, + 'metricsRegistry must be rebuilt on the second start()' + ) + assert.strictEqual( + httpServer.listenerCount('request'), + listenerCount1, + 'request listeners must not accumulate across start()→stop()→start() cycles' + ) + void registry1 + } finally { + server.stop() + } + }) + + await it('start() rolls back transportAttached when attachTransport throws — a subsequent start() succeeds', t => { + const server = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(server) + server.mockListen(t) + const proto = Reflect.getPrototypeOf(server) as { attachTransport: () => void } + const origAttach = proto.attachTransport.bind(server) + const httpServer = Reflect.get(server, 'httpServer') as Server + let throwOnce = true + ;(server as unknown as { attachTransport: () => void }).attachTransport = () => { + if (throwOnce) { + throwOnce = false + httpServer.on('request', () => undefined) + httpServer.on('upgrade', () => undefined) + throw new Error('attach failed') + } + origAttach() + } + try { + assert.throws(() => { + server.start() + }, /attach failed/) + assert.strictEqual( + Reflect.get(server, 'transportAttached'), + false, + 'rollback: transportAttached must be false after attachTransport throws' + ) + assert.strictEqual( + httpServer.listenerCount('request'), + 0, + 'rollback: any partially-registered request listeners must be stripped' + ) + assert.strictEqual( + httpServer.listenerCount('upgrade'), + 0, + 'rollback: any partially-registered upgrade listeners must be stripped' + ) + assert.doesNotThrow(() => { + server.start() + }, 'second start() after rollback must NOT throw the one-shot BaseError') + } finally { + server.stop() + } + }) + + await it('stop() invokes detachTransport synchronously and schedules registry.clear() as a microtask', async t => { + const server = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(server) + server.mockListen(t) + server.start() + const order: string[] = [] + const proto = Reflect.getPrototypeOf(server) as { detachTransport: () => void } + const origDetach = proto.detachTransport.bind(server) + ;(server as unknown as { detachTransport: () => void }).detachTransport = () => { + order.push('detach') + origDetach() + } + const registry = server.getMetricsRegistry() + assert(registry !== undefined, 'precondition: registry built') + const origClear = registry.clear.bind(registry) + ;(registry as unknown as { clear: () => void }).clear = () => { + order.push('clear') + origClear() + } + server.stop() + assert.deepStrictEqual( + order, + ['detach'], + 'detach must run synchronously inside stop(); clear is still scheduled' + ) + await Promise.resolve() + await Promise.resolve() + assert.deepStrictEqual( + order, + ['detach', 'clear'], + 'registry.clear() must settle on the microtask queue (Promise.finally), NOT setImmediate/setTimeout' + ) + }) + + await it('handleMetricsHttpRequest error path is a no-op when writableEnded=true AND headersSent=false (end-without-writeHead)', async t => { + const server = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(server) + server.mockListen(t) + try { + server.start() + const res = new MockServerResponse() + let writeHeadCount = 0 + const origWriteHead = res.writeHead.bind(res) + ;( + res as MockServerResponse & { + writeHead: (status: number, headers?: Record) => MockServerResponse + } + ).writeHead = (status: number, headers?: Record): MockServerResponse => { + writeHeadCount += 1 + return origWriteHead(status, headers) + } + Reflect.set( + server, + 'runMetricsScrape', + (_req: IncomingMessage, r: MockServerResponse): Promise => { + r.end('partial') + return Promise.reject(new Error('post-end fail')) + } + ) + server.emitRequest(buildMetricsRequest(), res) + await new Promise(resolve => { + setImmediate(resolve) + }) + await new Promise(resolve => { + setImmediate(resolve) + }) + assert.strictEqual( + res.headersSent, + false, + 'precondition: stub used end() without writeHead → headersSent stays false' + ) + assert.strictEqual( + res.writableEnded, + true, + 'precondition: stub called end() → writableEnded is true' + ) + assert.strictEqual( + writeHeadCount, + 0, + 'error path must NOT writeHead — `!res.writableEnded` is the gate, not `!res.headersSent` alone' + ) + assert.strictEqual(res.body, 'partial', 'body must not be overwritten by the error path') + } finally { + server.stop() + } + }) + + await it('stop() wipes pre-populated maps + caches AND resets transportAttached when detachTransport() throws (caught path)', t => { + const server = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(server) + server.mockListen(t) + server.start() + const { uiSvc } = populateLiveState(server) + const httpServer = Reflect.get(server, 'httpServer') as { + close: () => unknown + listenerCount: (event: string) => number + listening: boolean + } + t.mock.method(httpServer as never, 'close' as never, ((): unknown => httpServer) as never) + Object.defineProperty(httpServer, 'listening', { configurable: true, value: true }) + ;(server as unknown as { detachTransport: () => void }).detachTransport = () => { + throw new Error('detach boom') + } + assert.doesNotThrow(() => { + server.stop() + }, 'stop() must swallow detachTransport() throws and continue teardown') + assert.strictEqual( + Reflect.get(server, 'transportAttached'), + false, + 'inv-1 transportAttached reset even when detachTransport throws' + ) + assert.strictEqual(server.getMetricsRegistry(), undefined, 'inv-2 registry released') + assert.strictEqual(uiSvc.stopCalled, 1, 'inv-3 uiService.stop() called exactly once') + assert.strictEqual( + (Reflect.get(server, 'uiServices') as Map).size, + 0, + 'inv-4 uiServices cleared' + ) + assert.strictEqual( + (Reflect.get(server, 'responseHandlers') as Map).size, + 0, + 'inv-5 responseHandlers cleared' + ) + assert.strictEqual( + (Reflect.get(server, 'chargingStations') as Map).size, + 0, + 'inv-6 clearCaches() ran: chargingStations drained' + ) + assert.strictEqual( + (Reflect.get(server, 'chargingStationTemplates') as Set).size, + 0, + 'inv-7 clearCaches() ran: chargingStationTemplates drained' + ) + assert.strictEqual(httpServer.listenerCount('request'), 0, 'inv-8 request listeners stripped') + assert.doesNotThrow(() => { + server.start() + }, 'inv-9 fresh start() must succeed after stop() swallowed a detachTransport throw') + server.stop() + }) + + await it('stop() resets transportAttached in finally{} even when stopHttpServer() rethrows (uncaught path discriminator)', t => { + const server = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(server) + server.mockListen(t) + server.start() + const httpServer = Reflect.get(server, 'httpServer') as { + close: () => unknown + eventNames: () => (string | symbol)[] + listening: boolean + } + Object.defineProperty(httpServer, 'listening', { configurable: true, value: true }) + t.mock.method( + httpServer as never, + 'close' as never, + ((): unknown => { + throw new Error('close boom') + }) as never + ) + assert.throws( + () => { + server.stop() + }, + /close boom/, + 'unprotected stopHttpServer throw must propagate' + ) + assert.strictEqual( + httpServer.eventNames().length, + 0, + 'listener-strip ordering: removeAllListeners() MUST run BEFORE close() so ALL listener kinds (request/connection/clientError/upgrade/error) are stripped even when close() throws' + ) + assert.strictEqual( + Reflect.get(server, 'transportAttached'), + false, + 'finally{} placement: transportAttached reset even when an unprotected step rethrows' + ) + assert.doesNotThrow(() => { + server.start() + }, 'recovery: fresh start() must succeed after a rethrown stop()') + // Cleanup: the close mock is still active; suppress the trailing + // throw to avoid bleeding into subsequent tests' diagnostics. + Object.defineProperty(httpServer, 'listening', { configurable: true, value: false }) + server.stop() + }) + + await it('stop() continues teardown when a uiService.stop() throws (per-iteration protection)', t => { + const server = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(server) + server.mockListen(t) + server.start() + const services = Reflect.get(server, 'uiServices') as Map void }> + let peerStopped = false + services.set('rogue', { + stop: () => { + throw new Error('service boom') + }, + }) + services.set('peer', { + stop: () => { + peerStopped = true + }, + }) + assert.doesNotThrow(() => { + server.stop() + }, 'stop() must swallow a rogue uiService.stop()') + assert.strictEqual(peerStopped, true, 'peer uiService.stop() ran after rogue threw') + assert.strictEqual(services.size, 0, 'uiServices cleared after per-iteration catch') + assert.strictEqual( + Reflect.get(server, 'transportAttached'), + false, + 'transportAttached reset after rogue uiService caught' + ) + }) + + await it('metricsScrapeChain is re-armed across start()→stop()→start() cycles (monotonic identity sequence)', async t => { + const server = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(server) + server.addStation(buildStationData('station-chain')) + server.mockListen(t) + const getChain = (): Promise => Reflect.get(server, 'metricsScrapeChain') as Promise + try { + server.start() + const chain0 = getChain() + const r1 = new MockServerResponse() + server.emitRequest(buildMetricsRequest(), r1) + await once(r1, 'finish') + const chainAfterScrape1 = getChain() + assert.notStrictEqual( + chainAfterScrape1, + chain0, + 'scrape advances chain identity within a cycle' + ) + server.stop() + const chainAfterStop = getChain() + assert.notStrictEqual( + chainAfterStop, + chainAfterScrape1, + 'stop() rebinds chain to schedule registry.clear()' + ) + server.start() + const chain1 = getChain() + assert.notStrictEqual( + chain1, + chainAfterStop, + 'start() reseats chain to a fresh Promise.resolve() (no cross-cycle accumulation)' + ) + const r2 = new MockServerResponse() + server.emitRequest(buildMetricsRequest(), r2) + await once(r2, 'finish') + const chainAfterScrape2 = getChain() + const all = [chain0, chainAfterScrape1, chainAfterStop, chain1, chainAfterScrape2] + assert.strictEqual( + new Set(all).size, + all.length, + 'chain identity never reused across cycles (monotonic sequence)' + ) + } finally { + server.stop() + } + }) + + await it('stop() schedules registry.clear() against the captured registry (locks the .finally body, not just chain identity)', async t => { + const localServer = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(localServer) + localServer.addStation(buildStationData('station-clear')) + localServer.mockListen(t) + try { + localServer.start() + const r1 = new MockServerResponse() + localServer.emitRequest(buildMetricsRequest(), r1) + await once(r1, 'finish') + const registry = localServer.getMetricsRegistry() + assert.ok(registry !== undefined, 'precondition: registry present before stop()') + let clearCalls = 0 + const originalClear = registry.clear.bind(registry) + registry.clear = (): void => { + clearCalls += 1 + originalClear() + } + const chainBefore = Reflect.get(localServer, 'metricsScrapeChain') as Promise + localServer.stop() + const chainAfter = Reflect.get(localServer, 'metricsScrapeChain') as Promise + assert.notStrictEqual(chainAfter, chainBefore, 'precondition: stop() rebinds the chain') + await chainAfter + assert.strictEqual( + clearCalls, + 1, + 'stop() MUST invoke registry.clear() exactly once on the CAPTURED registry instance' + ) + assert.strictEqual( + localServer.getMetricsRegistry(), + undefined, + 'registry field released even though clear() ran on the captured handle' + ) + } finally { + localServer.stop() + } + }) + + await it('stop() runs registry.clear() even when metricsScrapeChain is in a REJECTED state (locks .finally semantics, not .then)', async t => { + const localServer = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(localServer) + localServer.addStation(buildStationData('station-rejected-chain')) + localServer.mockListen(t) + t.mock.method(logger, 'error', () => undefined) + try { + localServer.start() + const registry = localServer.getMetricsRegistry() + assert.ok(registry !== undefined, 'precondition: registry present before scrape') + registry.metrics = async (): Promise => { + await Promise.resolve() + throw new Error('scrape boom') + } + let clearCalls = 0 + const originalClear = registry.clear.bind(registry) + registry.clear = (): void => { + clearCalls += 1 + originalClear() + } + const res = new MockServerResponse() + localServer.emitRequest(buildMetricsRequest(), res) + await once(res, 'finish') + const rejectedChain = Reflect.get(localServer, 'metricsScrapeChain') as Promise + let rejected = false + await rejectedChain.catch(() => { + rejected = true + }) + assert.strictEqual( + rejected, + true, + 'precondition: scrape chain is in REJECTED state before stop()' + ) + localServer.stop() + const chainAfter = Reflect.get(localServer, 'metricsScrapeChain') as Promise + await chainAfter + assert.strictEqual( + clearCalls, + 1, + 'stop() MUST invoke registry.clear() exactly once even when metricsScrapeChain is rejected (locks `.finally`, not `.then`)' + ) + } finally { + localServer.stop() + } + }) + + await it('runMetricsScrape captures sampleCount pre-yield: mutating this.metricsSampleCount across the await does not synthesize a soft-cap warn (MED-1 regression lock)', async t => { + const cap = 999_999 + const sentinel = Number.MAX_SAFE_INTEGER + const localServer = new TestableUIHttpServer( + createMetricsConfig({ metrics: { enabled: true, softSampleCap: cap } }) + ) + enrichBootstrap(localServer) + localServer.mockListen(t) + const warnSpy = t.mock.method(logger, 'warn', () => undefined) + try { + localServer.start() + const registry = localServer.getMetricsRegistry() + assert.ok(registry !== undefined, 'precondition: registry present') + let releaseGate = (): void => undefined + const gated = new Promise(resolve => { + releaseGate = resolve + }) + const originalMetrics = registry.metrics.bind(registry) + registry.metrics = async (): Promise => { + const inner = originalMetrics() + await gated + Reflect.set(localServer, 'metricsSampleCount', sentinel) + return await inner + } + const res = new MockServerResponse() + localServer.emitRequest(buildMetricsRequest(), res) + await new Promise(resolve => { + setImmediate(resolve) + }) + releaseGate() + await once(res, 'finish') + const scrapeWarns = warnSpy.mock.calls.filter( + c => + typeof c.arguments[0] === 'string' && + (c.arguments[0] as string).includes(METRICS_SOFT_CAP_WARN_PREFIX) + ) + assert.strictEqual( + scrapeWarns.length, + 0, + 'soft-cap branch MUST read the pre-yield captured sampleCount; mutating this.metricsSampleCount across the await must not synthesize a warn' + ) + } finally { + localServer.stop() + } + }) + + await it('runMetricsScrape fires exactly one soft-cap warn at cap=1 with the captured sample count (MED-1 positive lock)', async t => { + const cap = 1 + const localServer = new TestableUIHttpServer( + createMetricsConfig({ metrics: { enabled: true, softSampleCap: cap } }) + ) + enrichBootstrap(localServer) + localServer.addStation(buildStationData('station-MED1-pos')) + localServer.mockListen(t) + const warnSpy = t.mock.method(logger, 'warn', () => undefined) + try { + localServer.start() + const res = new MockServerResponse() + localServer.emitRequest(buildMetricsRequest(), res) + await once(res, 'finish') + assert.strictEqual(res.statusCode, 200) + const sampleCount = (res.body ?? '') + .split('\n') + .filter(line => line.length > 0 && !line.startsWith('#')).length + assert.ok( + sampleCount > cap, + `precondition: scrape must produce > cap=${cap.toString()} samples; got ${sampleCount.toString()}` + ) + const scrapeWarns = warnSpy.mock.calls.filter( + c => + typeof c.arguments[0] === 'string' && + (c.arguments[0] as string).includes(METRICS_SOFT_CAP_WARN_PREFIX) + ) + assert.strictEqual( + scrapeWarns.length, + 1, + `soft-cap branch MUST fire exactly one warn at cap=${cap.toString()} with > ${cap.toString()} samples; got ${scrapeWarns.length.toString()}` + ) + const message = scrapeWarns[0].arguments[0] as unknown as string + assert.ok( + message.includes(`${sampleCount.toString()} samples`), + `warn message MUST embed the captured sampleCount=${sampleCount.toString()}; got: ${message}` + ) + assert.ok( + message.includes(`soft cap ${cap.toString()}`), + `warn message MUST embed the configured cap=${cap.toString()}; got: ${message}` + ) + } finally { + localServer.stop() + } + }) + + await it('soft-cap branch fires at cap=0 for any non-zero sample count (lower-boundary lock)', async t => { + const localServer = new TestableUIHttpServer( + createMetricsConfig({ metrics: { enabled: true, softSampleCap: 0 } }) + ) + enrichBootstrap(localServer) + localServer.mockListen(t) + const warnSpy = t.mock.method(logger, 'warn', () => undefined) + try { + localServer.start() + const res = new MockServerResponse() + localServer.emitRequest(buildMetricsRequest(), res) + await once(res, 'finish') + assert.strictEqual(res.statusCode, 200) + const scrapeWarns = warnSpy.mock.calls.filter( + c => + typeof c.arguments[0] === 'string' && + (c.arguments[0] as string).includes(METRICS_SOFT_CAP_WARN_PREFIX) + ) + assert.strictEqual( + scrapeWarns.length, + 1, + 'soft-cap branch MUST fire at cap=0 for any scrape with > 0 samples (strict-greater-than semantics; guards against an over-eager "cap=0 means disabled" early-exit refactor)' + ) + } finally { + localServer.stop() + } + }) + + await it('in-flight scrape survives concurrent stop()', async t => { + const localServer = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(localServer) + localServer.addStation(buildStationData('station-capture-lock')) + localServer.mockListen(t) + try { + localServer.start() + const registry = localServer.getMetricsRegistry() + assert.ok(registry !== undefined, 'precondition: registry present') + let releaseGate = (): void => undefined + const gated = new Promise(resolve => { + releaseGate = resolve + }) + const originalMetrics = registry.metrics.bind(registry) + registry.metrics = async (): Promise => { + const inner = originalMetrics() + await gated + return await inner + } + const res = new MockServerResponse() + localServer.emitRequest(buildMetricsRequest(), res) + await new Promise(resolve => { + setImmediate(resolve) + }) + localServer.stop() + assert.strictEqual( + localServer.getMetricsRegistry(), + undefined, + 'precondition: stop() nulled this.metricsRegistry mid-flight' + ) + releaseGate() + await once(res, 'finish') + assert.strictEqual( + res.statusCode, + 200, + 'in-flight scrape completes 200 — captured const registry survives mid-flight stop()' + ) + assert.match( + res.body ?? '', + /^# HELP /m, + 'response body is the OLD registry exposition (captured const, not the nulled field)' + ) + } finally { + localServer.stop() + } + }) + + await it('stop() is idempotent — a second stop() is a safe no-op (no throw, no chain rebind)', t => { + const server = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(server) + server.mockListen(t) + server.start() + server.stop() + const chainBefore = Reflect.get(server, 'metricsScrapeChain') as Promise + const registryBefore = server.getMetricsRegistry() + const attachedBefore = Reflect.get(server, 'transportAttached') as boolean + assert.doesNotThrow(() => { + server.stop() + }, 'second stop() must not throw') + assert.strictEqual( + server.getMetricsRegistry(), + registryBefore, + '2nd stop: registry stays undefined' + ) + assert.strictEqual( + Reflect.get(server, 'transportAttached'), + attachedBefore, + '2nd stop: transportAttached stays false' + ) + assert.strictEqual( + Reflect.get(server, 'metricsScrapeChain'), + chainBefore, + '2nd stop: chain MUST NOT be rebound when metricsRegistry is already undefined' + ) + }) + + await it('stop() clears the client-notification debounce timer on every invocation (outside the metricsRegistry guard)', t => { + const localServer = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(localServer) + localServer.mockListen(t) + localServer.start() + ;( + localServer as unknown as { scheduleClientNotification: () => void } + ).scheduleClientNotification() + const installed = Reflect.get(localServer, 'clientNotificationDebounceTimer') as + | NodeJS.Timeout + | undefined + assert.ok(installed !== undefined, 'precondition: debounce timer installed') + const clearedHandles: unknown[] = [] + const originalClearTimeout = globalThis.clearTimeout + globalThis.clearTimeout = ((handle?: NodeJS.Timeout): void => { + clearedHandles.push(handle) + originalClearTimeout(handle) + }) as typeof globalThis.clearTimeout + try { + // Measure delta within each stop() so that clearTimeout calls + // from scheduleClientNotification() (which clears its own stale + // handle before installing a fresh one) don't contaminate the + // count attributable to stop(). + const beforeStop1 = clearedHandles.length + localServer.stop() + const afterStop1 = clearedHandles.length + assert.strictEqual( + afterStop1 - beforeStop1, + 1, + '1st stop() MUST invoke clearTimeout exactly once' + ) + assert.strictEqual( + clearedHandles[afterStop1 - 1], + installed, + '1st stop() clears the originally installed handle' + ) + ;( + localServer as unknown as { scheduleClientNotification: () => void } + ).scheduleClientNotification() + const reinstalled = Reflect.get(localServer, 'clientNotificationDebounceTimer') as + | NodeJS.Timeout + | undefined + assert.ok(reinstalled !== undefined, 'precondition: 2nd timer installed after first stop()') + const beforeStop2 = clearedHandles.length + localServer.stop() + const afterStop2 = clearedHandles.length + assert.strictEqual( + afterStop2 - beforeStop2, + 1, + '2nd stop() MUST invoke clearTimeout exactly once — the call sits OUTSIDE the metricsRegistry guard' + ) + assert.strictEqual( + clearedHandles[afterStop2 - 1], + reinstalled, + '2nd stop() clears the re-installed handle (no leak across cycles)' + ) + } finally { + globalThis.clearTimeout = originalClearTimeout + } + }) + + await it('attachTransport() observes transportAttached === true (one-shot guard set BEFORE hook invocation)', t => { + const server = new TestableUIHttpServer(createMetricsConfig()) + enrichBootstrap(server) + server.mockListen(t) + let observedDuringAttach: unknown = 'unset' + const proto = Reflect.getPrototypeOf(server) as { attachTransport: () => void } + const origAttach = proto.attachTransport.bind(server) + ;(server as unknown as { attachTransport: () => void }).attachTransport = () => { + observedDuringAttach = Reflect.get(server, 'transportAttached') + origAttach() + } + try { + server.start() + assert.strictEqual( + observedDuringAttach, + true, + 'transportAttached MUST be true while attachTransport runs (guards re-entry / nested start())' + ) + } finally { + server.stop() + } + }) }) diff --git a/tests/charging-station/ui-server/UIServerTestUtils.ts b/tests/charging-station/ui-server/UIServerTestUtils.ts index f7a9f2c6..fca39ea9 100644 --- a/tests/charging-station/ui-server/UIServerTestUtils.ts +++ b/tests/charging-station/ui-server/UIServerTestUtils.ts @@ -4,8 +4,9 @@ */ import type { IncomingMessage } from 'node:http' import type { Duplex } from 'node:stream' +import type { mock } from 'node:test' -import { EventEmitter } from 'node:events' +import { EventEmitter, once } from 'node:events' import type { IBootstrap } from '../../../src/charging-station/IBootstrap.js' import type { @@ -61,6 +62,14 @@ export class TestableUIWebSocketServer extends UIWebSocketServer { this.responseHandlers.set(uuid, ws as never) } + public addStation (data: ChargingStationData): void { + this.setChargingStationData(data.stationInfo.hashId, data) + } + + public emitRequest (req: IncomingMessage, res: MockServerResponse): void { + this.httpServer.emit('request', req, res) + } + public emitUpgrade (req: IncomingMessage, socket: Duplex, head = Buffer.alloc(0)): void { this.httpServer.emit('upgrade', req, socket, head) } @@ -82,6 +91,15 @@ export class TestableUIWebSocketServer extends UIWebSocketServer { return this.uiServices.get(version) } + public getWebSocketServer (): object { + return Reflect.get(this, 'webSocketServer') + } + + 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) + } + /** * Register a mock UI service for testing. * @param version - Protocol version string to register @@ -217,7 +235,9 @@ export class MockServerResponse extends EventEmitter { public destroyed = false public ended = false public headers: Record = {} + public headersSent = false public statusCode?: number + public writableEnded = false private chunks: Buffer[] = [] public destroy (): this { @@ -233,6 +253,7 @@ export class MockServerResponse extends EventEmitter { this.body = this.bodyBuffer.toString('binary') } this.ended = true + this.writableEnded = true this.emit('finish') return this } @@ -258,6 +279,7 @@ export class MockServerResponse extends EventEmitter { if (headers != null) { this.headers = headers } + this.headersSent = true return this } } @@ -334,6 +356,101 @@ export const createProtocolRequest = ( return [uuid, procedureName, payload] } +/** + * Bounded multi-response drain. Awaits every `'finish'` event with a + * timeout to prevent microtask leaks into the next test's logger mocks, + * then flushes the trailing scrape-chain microtasks: `runMetricsScrape` + * ends the response inside a `.then()` continuation on + * `metricsScrapeChain`; the chain resolution lands one microtask later. + * Two `setImmediate` hops guarantee both the continuation and the chain + * settlement run before the next test installs its logger mocks. + * + * The default 5000 ms timeout is sufficient for ≤200 in-flight scrapes + * serialized through `metricsScrapeChain`; override at the call site for + * larger bursts. + * + * Throws a diagnostic error naming the pending count when the timeout + * fires, so a leaked async scrape is identifiable without grepping the + * suite output. + * @param responses - Responses whose `'finish'` events must drain + * @param opts - Drain options + * @param opts.timeoutMs - Upper bound in milliseconds (default 5000) + */ +export const drainResponses = async ( + responses: readonly MockServerResponse[], + opts: { timeoutMs?: number } = {} +): Promise => { + const { timeoutMs = 5_000 } = opts + const settle = responses.map(r => (r.writableEnded ? Promise.resolve() : once(r, 'finish'))) + let timer: ReturnType | undefined + const timerPromise = new Promise<'timeout'>(resolve => { + timer = setTimeout(() => { + resolve('timeout') + }, timeoutMs) + timer.unref() + }) + try { + const outcome = await Promise.race([ + Promise.allSettled(settle).then(() => 'ok' as const), + timerPromise, + ]) + if (outcome === 'timeout') { + const pending = responses.filter(r => !r.writableEnded).length + throw new Error( + `drainResponses: ${pending.toString()}/${responses.length.toString()} response(s) did not finish in ${timeoutMs.toString()}ms` + ) + } + await new Promise(resolve => { + setImmediate(resolve) + }) + await new Promise(resolve => { + setImmediate(resolve) + }) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + +/** + * Single-response wait with timeout. Replaces bare `await once(res, 'finish')` + * call sites that would otherwise hang the whole suite if the scrape promise + * rejects. + * @param res - Response to await + * @param opts - Wait options + * @param opts.timeoutMs - Upper bound in milliseconds (default 5000) + */ +export const awaitFinish = async ( + res: MockServerResponse, + opts: { timeoutMs?: number } = {} +): Promise => { + await drainResponses([res], opts) +} + +/** + * Extract a single gauge value from a Prometheus exposition body. Returns + * `undefined` if the gauge is missing or malformed so assertion failures + * carry an informative message. + * @param body - Raw exposition text + * @param name - Gauge metric name (e.g. `'simulator_started'`) + * @param labels - Optional label map to disambiguate label sets + * @returns The parsed gauge value, or `undefined` if absent or malformed + */ +export const extractGaugeValue = ( + body: string, + name: string, + labels?: Readonly> +): number | undefined => { + const labelExpr = + labels === undefined || Object.keys(labels).length === 0 + ? '' + : `\\{${Object.entries(labels) + .map(([k, v]) => `${k}="${v.replace(/[\\"]/g, '\\$&')}"`) + .join(',')}\\}` + const re = new RegExp(`^${name}${labelExpr}\\s+([-0-9.eE+]+)$`, 'm') + const m = re.exec(body) + return m === null ? undefined : Number(m[1]) +} + /** * Mock UI service behavior mode for testing different request handler scenarios. */ diff --git a/tests/charging-station/ui-server/UIWebSocketServer.test.ts b/tests/charging-station/ui-server/UIWebSocketServer.test.ts index 1700b1b4..bdb27fca 100644 --- a/tests/charging-station/ui-server/UIWebSocketServer.test.ts +++ b/tests/charging-station/ui-server/UIWebSocketServer.test.ts @@ -3,28 +3,116 @@ * @description Unit tests for WebSocket-based UI server and response handling */ +import type { IncomingMessage } from 'node:http' import type { Duplex } from 'node:stream' import assert from 'node:assert/strict' import { afterEach, describe, it } from 'node:test' -import type { UUIDv4 } from '../../../src/types/index.js' +import type { + ChargingStationData, + TemplateStatistics, + UIServerConfiguration, + UUIDv4, +} from '../../../src/types/index.js' -import { ProcedureName, ResponseStatus } from '../../../src/types/index.js' +import { + ApplicationProtocol, + AuthenticationType, + ConnectorStatusEnum, + OCPP16AvailabilityType, + OCPPVersion, + ProcedureName, + ResponseStatus, +} from '../../../src/types/index.js' import { logger } from '../../../src/utils/Logger.js' import { createLoggerMocks, standardCleanup } from '../../helpers/TestLifecycleHelpers.js' import { TEST_UUID } from './UIServerTestConstants.js' import { + awaitFinish, createMockIncomingMessage, createMockUIServerConfiguration, createMockUIServerConfigurationWithAuth, createMockUIService, createMockUIWebSocket, + drainResponses, + extractGaugeValue, + MockServerResponse, MockUIServiceMode, MockUpgradeSocket, TestableUIWebSocketServer, } from './UIServerTestUtils.js' +const createWsMetricsConfig = ( + overrides: Partial = {} +): UIServerConfiguration => + createMockUIServerConfiguration({ + metrics: { enabled: true }, + options: { host: '127.0.0.1', port: 0 }, + type: ApplicationProtocol.WS, + ...overrides, + }) + +const buildWsMetricsRequest = (overrides: Partial = {}): IncomingMessage => + createMockIncomingMessage({ + complete: true, + headers: { host: 'localhost' }, + method: 'GET', + socket: { encrypted: false, remoteAddress: '127.0.0.1' } as never, + url: '/metrics', + ...overrides, + }) + +const enrichBootstrapForMetrics = (server: TestableUIWebSocketServer, 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 buildSimpleStation = (hashId: string): ChargingStationData => + ({ + connectors: [ + { + connectorId: 1, + connectorStatus: { + availability: OCPP16AvailabilityType.Operative, + MeterValues: [], + 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, + }) as unknown as ChargingStationData + await describe('UIWebSocketServer', async () => { afterEach(() => { standardCleanup() @@ -335,4 +423,419 @@ await describe('UIWebSocketServer', async () => { server.stop() } }) + + await it('should not leak webSocketServer "connection" listeners across start→stop→start cycles', t => { + const config = createMockUIServerConfiguration({ + options: { host: '127.0.0.1', port: 0 }, + type: ApplicationProtocol.WS, + }) + const server = new TestableUIWebSocketServer(config) + server.mockListen(t) + const wsServer = server.getWebSocketServer() as { + listenerCount: (event: string) => number + } + const baseline = wsServer.listenerCount('connection') + try { + for (let cycle = 0; cycle < 3; cycle += 1) { + server.start() + assert.strictEqual( + wsServer.listenerCount('connection'), + baseline + 1, + `cycle ${cycle.toString()}: exactly one 'connection' listener after start()` + ) + server.stop() + assert.strictEqual( + wsServer.listenerCount('connection'), + baseline, + `cycle ${cycle.toString()}: 'connection' listeners released after stop() (no leak)` + ) + } + } finally { + try { + server.stop() + } catch { + /* already stopped */ + } + } + }) + + await describe('metrics endpoint when uiServer.type=ws (issue #1917)', async () => { + await it('should serve Prometheus exposition on GET /metrics when enabled', async t => { + const server = new TestableUIWebSocketServer(createWsMetricsConfig()) + enrichBootstrapForMetrics(server) + server.mockListen(t) + try { + server.start() + const res = new MockServerResponse() + server.emitRequest(buildWsMetricsRequest(), res) + await awaitFinish(res) + assert.strictEqual(res.statusCode, 200) + assert.match(res.headers['Content-Type'] ?? '', /^text\/plain;\s*version=0\.0\.4/) + assert.strictEqual( + extractGaugeValue(res.body ?? '', 'simulator_started'), + 1, + 'simulator_started must be 1 from the enrichBootstrapForMetrics fixture' + ) + assert.match(res.body ?? '', /^# HELP simulator_started /m) + assert.match(res.body ?? '', /^# TYPE simulator_started gauge$/m) + } finally { + server.stop() + } + }) + + await it('should return 404 on GET /metrics when metrics.enabled=false (default)', t => { + const server = new TestableUIWebSocketServer( + createMockUIServerConfiguration({ + options: { host: '127.0.0.1', port: 0 }, + type: ApplicationProtocol.WS, + }) + ) + enrichBootstrapForMetrics(server) + server.mockListen(t) + try { + server.start() + const res = new MockServerResponse() + server.emitRequest(buildWsMetricsRequest(), res) + assert.strictEqual(res.statusCode, 404) + } finally { + server.stop() + } + }) + + await it('should inherit AccessPolicy denial — 403 on non-loopback without TLS (F5)', t => { + const server = new TestableUIWebSocketServer( + createWsMetricsConfig({ + accessPolicy: { + allowedHosts: ['gateway.example.com'], + allowedOrigins: [], + allowLoopbackProxy: false, + requireTlsForNonLoopback: true, + trustedProxies: [], + }, + }) + ) + enrichBootstrapForMetrics(server) + server.mockListen(t) + try { + server.start() + const res = new MockServerResponse() + server.emitRequest( + buildWsMetricsRequest({ + headers: { host: 'gateway.example.com' }, + socket: { encrypted: false, remoteAddress: '203.0.113.10' } as never, + }), + res + ) + assert.strictEqual(res.statusCode, 403) + } finally { + server.stop() + } + }) + + await it('should return 401 without credentials when authentication is enabled', t => { + const server = new TestableUIWebSocketServer( + createWsMetricsConfig({ + authentication: { + enabled: true, + password: 'pw', + type: AuthenticationType.BASIC_AUTH, + username: 'user', + }, + }) + ) + enrichBootstrapForMetrics(server) + server.mockListen(t) + try { + server.start() + const res = new MockServerResponse() + server.emitRequest(buildWsMetricsRequest(), res) + assert.strictEqual(res.statusCode, 401) + assert.strictEqual(res.headers['WWW-Authenticate'], 'Basic realm=users') + } finally { + server.stop() + } + }) + + await it('should return 200 with valid Basic Auth credentials', async t => { + const server = new TestableUIWebSocketServer( + createWsMetricsConfig({ + authentication: { + enabled: true, + password: 'pw', + type: AuthenticationType.BASIC_AUTH, + username: 'user', + }, + }) + ) + enrichBootstrapForMetrics(server) + server.mockListen(t) + try { + server.start() + const credentials = Buffer.from('user:pw').toString('base64') + const res = new MockServerResponse() + server.emitRequest( + buildWsMetricsRequest({ + headers: { authorization: `Basic ${credentials}`, host: 'localhost' }, + }), + res + ) + await awaitFinish(res) + assert.strictEqual(res.statusCode, 200) + } finally { + server.stop() + } + }) + + await it('should return 429 under rate-limit burst on /metrics and drain all responses', async t => { + const server = new TestableUIWebSocketServer(createWsMetricsConfig()) + enrichBootstrapForMetrics(server) + server.mockListen(t) + try { + server.start() + const responses: MockServerResponse[] = [] + for (let i = 0; i < 200; i++) { + const res = new MockServerResponse() + server.emitRequest(buildWsMetricsRequest(), res) + responses.push(res) + } + const sync429 = responses.filter(r => r.statusCode === 429).length + assert.ok( + sync429 >= 1, + `Expected at least one synchronous 429 in burst on /metrics; saw ${sync429.toString()}` + ) + await drainResponses(responses) + for (const r of responses) { + assert.strictEqual(r.writableEnded, true) + } + } finally { + server.stop() + } + }) + + await it('should keep WS upgrade handler functional after a /metrics scrape', async t => { + const server = new TestableUIWebSocketServer(createWsMetricsConfig()) + enrichBootstrapForMetrics(server) + server.addStation(buildSimpleStation('station-ws-after-scrape')) + server.mockListen(t) + try { + server.start() + const metricsRes = new MockServerResponse() + server.emitRequest(buildWsMetricsRequest(), metricsRes) + await awaitFinish(metricsRes) + assert.strictEqual(metricsRes.statusCode, 200) + + const wss = server.getWebSocketServer() as { + handleUpgrade: (...args: unknown[]) => void + } + const handleUpgradeSpy = t.mock.method( + wss, + 'handleUpgrade', + (_req: unknown, _socket: unknown, _head: unknown, cb: (ws: unknown) => void) => { + cb({}) + } + ) + + const socket = new MockUpgradeSocket() + server.emitUpgrade( + createMockIncomingMessage({ + headers: { + connection: 'Upgrade', + host: 'localhost', + 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'sec-websocket-protocol': 'ui0.0.1', + 'sec-websocket-version': '13', + upgrade: 'websocket', + }, + socket: { encrypted: false, remoteAddress: '127.0.0.1' } as never, + }), + socket as unknown as Duplex + ) + + assert.strictEqual( + handleUpgradeSpy.mock.calls.length, + 1, + 'WebSocketServer.handleUpgrade must run after a metrics scrape' + ) + assert.strictEqual( + socket.destroyed, + false, + 'upgrade socket must not be destroyed by an HTTP-rejection write' + ) + } finally { + server.stop() + } + }) + + await it('should return 404 (not 401) on GET /metrics when metrics.enabled=false and authentication.enabled=true', t => { + const server = new TestableUIWebSocketServer( + createMockUIServerConfiguration({ + authentication: { + enabled: true, + password: 'pw', + type: AuthenticationType.BASIC_AUTH, + username: 'user', + }, + options: { host: '127.0.0.1', port: 0 }, + type: ApplicationProtocol.WS, + }) + ) + enrichBootstrapForMetrics(server) + server.mockListen(t) + try { + server.start() + const res = new MockServerResponse() + server.emitRequest(buildWsMetricsRequest(), res) + assert.strictEqual(res.statusCode, 404) + assert.strictEqual(res.headers['WWW-Authenticate'], undefined) + } finally { + server.stop() + } + }) + + await it('should respond 200 with empty body on HEAD /metrics per RFC 9110 §9.3.2', async t => { + const server = new TestableUIWebSocketServer(createWsMetricsConfig()) + enrichBootstrapForMetrics(server) + server.mockListen(t) + try { + server.start() + const res = new MockServerResponse() + server.emitRequest(buildWsMetricsRequest({ method: 'HEAD' }), res) + await awaitFinish(res) + assert.strictEqual(res.statusCode, 200) + assert.match(res.headers['Content-Type'] ?? '', /^text\/plain;\s*version=0\.0\.4/) + assert.strictEqual(res.body, undefined, 'HEAD response body must be empty') + } finally { + server.stop() + } + }) + + await it('should return 404 on POST /metrics (transport-method parity with HTTP/MCP)', t => { + const server = new TestableUIWebSocketServer(createWsMetricsConfig()) + enrichBootstrapForMetrics(server) + server.mockListen(t) + try { + server.start() + const res = new MockServerResponse() + server.emitRequest(buildWsMetricsRequest({ method: 'POST' }), res) + assert.strictEqual(res.statusCode, 404) + assert.strictEqual(res.headers['Content-Type'], 'text/plain') + } finally { + server.stop() + } + }) + + await it('should call req.destroy() AFTER renderDenial on the 404 fallback (parity with MCP)', t => { + const server = new TestableUIWebSocketServer(createWsMetricsConfig()) + enrichBootstrapForMetrics(server) + server.mockListen(t) + try { + server.start() + const order: string[] = [] + const req = buildWsMetricsRequest({ complete: false, url: '/unknown' }) + ;(req as IncomingMessage & { destroy: () => IncomingMessage }).destroy = () => { + order.push('destroy') + return req + } + const res = new MockServerResponse() + const origEnd = res.end.bind(res) + ;(res as MockServerResponse & { end: (data?: string) => MockServerResponse }).end = ( + data?: string + ): MockServerResponse => { + order.push('end') + return origEnd(data) + } + server.emitRequest(req, res) + assert.strictEqual(res.statusCode, 404) + assert.deepStrictEqual(order, ['end', 'destroy']) + } finally { + server.stop() + } + }) + + await it('should emit explicit Content-Length on both HEAD and GET /metrics (RFC 9110 §9.3.2 parity)', async t => { + const server = new TestableUIWebSocketServer(createWsMetricsConfig()) + enrichBootstrapForMetrics(server) + server.mockListen(t) + try { + server.start() + const getRes = new MockServerResponse() + const headRes = new MockServerResponse() + server.emitRequest(buildWsMetricsRequest(), getRes) + server.emitRequest(buildWsMetricsRequest({ method: 'HEAD' }), headRes) + await drainResponses([getRes, headRes]) + const getLen = Number(getRes.headers['Content-Length']) + const headLen = Number(headRes.headers['Content-Length']) + assert.strictEqual( + getLen, + Buffer.byteLength(getRes.body ?? '', 'utf8'), + 'GET Content-Length must equal body byte length' + ) + assert.strictEqual( + headLen, + getLen, + 'HEAD Content-Length must equal GET length (RFC 9110 §9.3.2)' + ) + assert.strictEqual(headRes.body, undefined, 'HEAD body must be empty') + } finally { + server.stop() + } + }) + + await it('should treat req.method=undefined as non-metrics (isMetricsRequest enforces GET/HEAD)', t => { + const server = new TestableUIWebSocketServer(createWsMetricsConfig()) + enrichBootstrapForMetrics(server) + server.mockListen(t) + try { + server.start() + const req = buildWsMetricsRequest() + ;(req as { method?: string }).method = undefined + const res = new MockServerResponse() + server.emitRequest(req, res) + assert.notStrictEqual(res.statusCode, 200) + assert.strictEqual(res.statusCode, 404) + } finally { + server.stop() + } + }) + + await it('should return 404 on /metrics after stop() clears the registry', t => { + const server = new TestableUIWebSocketServer(createWsMetricsConfig()) + enrichBootstrapForMetrics(server) + server.mockListen(t) + try { + server.start() + server.stop() + const res = new MockServerResponse() + server.emitRequest(buildWsMetricsRequest(), res) + assert.strictEqual(res.statusCode, 404) + } finally { + server.stop() + } + }) + + await it('should NOT call req.destroy() on the 404 fallback when req.complete === true (HTTP/1.1)', t => { + const server = new TestableUIWebSocketServer(createWsMetricsConfig()) + enrichBootstrapForMetrics(server) + server.mockListen(t) + try { + server.start() + let destroyCount = 0 + const req = buildWsMetricsRequest({ complete: true, url: '/unknown' }) + ;(req as IncomingMessage & { destroy: () => IncomingMessage }).destroy = () => { + destroyCount++ + return req + } + const res = new MockServerResponse() + server.emitRequest(req, res) + assert.strictEqual(res.statusCode, 404) + assert.strictEqual( + destroyCount, + 0, + 'req.destroy() must NOT be called when req.complete === true' + ) + } finally { + server.stop() + } + }) + }) })