Implement exporter using nocap from nostr-watch along with tests
This commit is contained in:
parent
9c6aef1d92
commit
0531a23680
17
.env.example
Normal file
17
.env.example
Normal file
@ -0,0 +1,17 @@
|
||||
export RELAYS=wss://offchain.pub,wss://nostr.bitcoiner.social
|
||||
# 0 = run probes on each /metrics scrape; set >0 for periodic background probing.
|
||||
# Example: 300000 = every 5 minutes.
|
||||
export PROBE_INTERVAL_MS=300000
|
||||
export PROBE_TIMEOUT_MS=10000
|
||||
export LISTEN_ADDR=0.0.0.0
|
||||
export PORT=9464
|
||||
export LOG_LEVEL=info
|
||||
|
||||
# Optional write confirmation probe settings:
|
||||
export WRITE_CHECK_ENABLED=true
|
||||
export WRITE_CHECK_KIND=30078
|
||||
# WRITE_CHECK_PRIVKEY accepts either:
|
||||
# - nsec1... string
|
||||
# - 64-char hex private key
|
||||
# If omitted or invalid, relay-exporter generates an ephemeral key at runtime.
|
||||
#export WRITE_CHECK_PRIVKEY=nsec1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -7,6 +7,7 @@ node_modules
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
/dist
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@ -24,4 +25,5 @@ vite.config.ts.timestamp-*
|
||||
.data
|
||||
|
||||
.env
|
||||
archive
|
||||
archive
|
||||
result
|
||||
135
README.md
Normal file
135
README.md
Normal file
@ -0,0 +1,135 @@
|
||||
# relay-exporter
|
||||
|
||||
Production-oriented Prometheus exporter for monitoring a fixed set of Nostr relays.
|
||||
|
||||
## What it does
|
||||
|
||||
- Probes each configured relay either on an interval, or on-demand when `/metrics` is scraped.
|
||||
- Uses `@nostrwatch/nocap` for `open` + `read` checks.
|
||||
- Optionally performs a low-noise write confirmation check (kind `30078` by default) using deterministic `d` tags.
|
||||
- Exposes:
|
||||
- `/metrics` for Prometheus scraping
|
||||
- `/healthz` for process and probe-loop health
|
||||
- Publishes default process metrics via `prom-client`.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
If you are bootstrapping from scratch, these are the exact dependency commands:
|
||||
|
||||
```bash
|
||||
pnpm add @nostrwatch/nocap prom-client nostr-tools p-limit
|
||||
pnpm add -D typescript tsx @types/node
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy and edit:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Example: run probes every 5 minutes instead of on each scrape:
|
||||
|
||||
```bash
|
||||
export PROBE_INTERVAL_MS=300000
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
|
||||
- `RELAYS` (required): comma-separated `wss://` URLs
|
||||
- `PROBE_INTERVAL_MS` (default: `0`; `0` means run probes on each `/metrics` scrape)
|
||||
- `PROBE_TIMEOUT_MS` (default: `10000`)
|
||||
- `LISTEN_ADDR` (default: `0.0.0.0`)
|
||||
- `PORT` (default: `9464`)
|
||||
- `LOG_LEVEL` (default: `info`; one of `debug|info|warn|error`)
|
||||
- `WRITE_CHECK_ENABLED` (default: `true`)
|
||||
- `WRITE_CHECK_KIND` (default: `30078`)
|
||||
- `WRITE_CHECK_PRIVKEY` (optional; supports `nsec1...` or 64-char hex)
|
||||
|
||||
### Write confirmation key material
|
||||
|
||||
- `WRITE_CHECK_PRIVKEY` may be an `nsec1...` value or a 64-character hex private key.
|
||||
- `WRITE_CHECK_PUBKEY` is not needed; write-check pubkey is always derived from the private key.
|
||||
- If `WRITE_CHECK_PRIVKEY` is missing or invalid and write checks are enabled, the exporter generates an ephemeral key for the running process and continues write checks.
|
||||
- Private key values are never logged.
|
||||
|
||||
## Run
|
||||
|
||||
Development with auto-reload:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Production build:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm start
|
||||
```
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Exposed metrics
|
||||
|
||||
Relay-level labels use `{relay}` unless stated:
|
||||
|
||||
- `nostr_relay_up` (gauge)
|
||||
- `nostr_relay_open_ok` (gauge)
|
||||
- `nostr_relay_read_ok` (gauge)
|
||||
- `nostr_relay_write_confirm_ok` (gauge)
|
||||
- `nostr_relay_open_duration_ms` (gauge)
|
||||
- `nostr_relay_read_duration_ms` (gauge)
|
||||
- `nostr_relay_write_duration_ms` (gauge, `-1` when unavailable/disabled)
|
||||
- `nostr_relay_last_success_unixtime` (gauge)
|
||||
- `nostr_relay_probe_errors_total{relay,check}` (counter)
|
||||
- `nostr_relay_probe_runs_total{relay,result}` (counter; `result=success|failure`)
|
||||
|
||||
Also includes all default Node.js process/runtime metrics from `prom-client`.
|
||||
|
||||
## Prometheus scrape config example
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: nostr-relay-exporter
|
||||
scrape_interval: 15s
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets:
|
||||
- "relay-exporter.internal:9464"
|
||||
```
|
||||
|
||||
## Example Grafana queries
|
||||
|
||||
- Relay up status by relay:
|
||||
- `max by (relay) (nostr_relay_up)`
|
||||
- Open latency:
|
||||
- `avg_over_time(nostr_relay_open_duration_ms[5m])`
|
||||
- Read latency:
|
||||
- `avg_over_time(nostr_relay_read_duration_ms[5m])`
|
||||
- Write confirmation success ratio (15m):
|
||||
- `sum by (relay) (increase(nostr_relay_probe_runs_total{result="success"}[15m])) / sum by (relay) (increase(nostr_relay_probe_runs_total[15m]))`
|
||||
- Probe errors by check:
|
||||
- `sum by (relay, check) (increase(nostr_relay_probe_errors_total[15m]))`
|
||||
|
||||
## Health endpoint
|
||||
|
||||
- `GET /healthz` returns:
|
||||
- `200` when process is running and probe data is fresh enough
|
||||
- `503` when shutting down or probe data is stale/not yet available
|
||||
|
||||
## Notes
|
||||
|
||||
- Relay probes are isolated; one relay failure does not block others.
|
||||
- `nocap` default adapters are explicitly loaded before checks.
|
||||
- Probe concurrency is bounded in code (`DEFAULT_PROBE_CONCURRENCY` in `src/config.ts`).
|
||||
- Graceful shutdown handles `SIGINT` and `SIGTERM`.
|
||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1773964973,
|
||||
"narHash": "sha256-NV/J+tTER0P5iJhUDL/8HO5MDjDceLQPRUYgdmy5wXw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "812b3986fd1568f7a858f97fcf425ad996ba7d25",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
174
flake.nix
Normal file
174
flake.nix
Normal file
@ -0,0 +1,174 @@
|
||||
{
|
||||
description = "Nostr relay Prometheus exporter";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
inherit (nixpkgs.lib) genAttrs;
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
forAllSystems = genAttrs systems;
|
||||
|
||||
mkPackage = pkgs:
|
||||
let
|
||||
pname = "relay-exporter";
|
||||
version = "0.1.0";
|
||||
src = pkgs.lib.cleanSourceWith {
|
||||
src = ./.;
|
||||
filter = path: type:
|
||||
!(pkgs.lib.hasInfix "/.git/" path)
|
||||
&& !(pkgs.lib.hasInfix "/node_modules/" path)
|
||||
&& !(pkgs.lib.hasInfix "/dist/" path);
|
||||
};
|
||||
in
|
||||
pkgs.stdenv.mkDerivation {
|
||||
inherit pname version;
|
||||
inherit src;
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkgs.nodejs_22
|
||||
pkgs.pnpm.configHook
|
||||
pkgs.makeWrapper
|
||||
];
|
||||
|
||||
pnpmDeps = pkgs.pnpm.fetchDeps {
|
||||
inherit pname version src;
|
||||
fetcherVersion = 1;
|
||||
hash = "sha256-n3pn4NVBVauwE2FWfMsulmn+KgCkcV32XJD6vX1NIB0=";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
pnpm run build
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out/app $out/bin
|
||||
cp -r dist package.json node_modules $out/app/
|
||||
makeWrapper ${pkgs.nodejs_22}/bin/node $out/bin/relay-exporter \
|
||||
--set NODE_ENV production \
|
||||
--add-flags "$out/app/dist/index.js"
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Prometheus exporter for Nostr relay health checks";
|
||||
mainProgram = "relay-exporter";
|
||||
license = pkgs.lib.licenses.mit;
|
||||
platforms = pkgs.lib.platforms.linux;
|
||||
};
|
||||
};
|
||||
in
|
||||
rec {
|
||||
packages = forAllSystems (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
pkg = mkPackage pkgs;
|
||||
in
|
||||
{
|
||||
relay-exporter = pkg;
|
||||
default = pkg;
|
||||
});
|
||||
|
||||
devShells = forAllSystems (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.nodejs_22
|
||||
pkgs.pnpm_10
|
||||
];
|
||||
};
|
||||
});
|
||||
|
||||
overlays.default = final: prev: {
|
||||
relay-exporter = self.packages.${prev.stdenv.hostPlatform.system}.relay-exporter;
|
||||
};
|
||||
|
||||
nixosModules.default =
|
||||
{ config, lib, pkgs, ... }:
|
||||
let
|
||||
inherit (lib) mkEnableOption mkIf mkOption types;
|
||||
cfg = config.services.relay-exporter;
|
||||
system = pkgs.stdenv.hostPlatform.system;
|
||||
defaultPackage = self.packages.${system}.relay-exporter;
|
||||
in
|
||||
{
|
||||
options.services.relay-exporter = {
|
||||
enable = mkEnableOption "relay-exporter service";
|
||||
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = defaultPackage;
|
||||
defaultText = "self.packages.${system}.relay-exporter";
|
||||
description = "Derivation that provides relay-exporter.";
|
||||
};
|
||||
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 9464;
|
||||
description = "HTTP listen port for relay-exporter.";
|
||||
};
|
||||
|
||||
listenAddr = mkOption {
|
||||
type = types.str;
|
||||
default = "0.0.0.0";
|
||||
description = "Listen address for relay-exporter.";
|
||||
};
|
||||
|
||||
stateDirectory = mkOption {
|
||||
type = types.str;
|
||||
default = "relay-exporter";
|
||||
description = "Name of the state directory managed by systemd.";
|
||||
};
|
||||
|
||||
environment = mkOption {
|
||||
type = types.attrsOf types.str;
|
||||
default = { };
|
||||
description = "Additional environment variables for relay-exporter.";
|
||||
};
|
||||
|
||||
environmentFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = "Optional environment file consumed by systemd.";
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
systemd.services.relay-exporter = {
|
||||
description = "Nostr relay Prometheus exporter";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
|
||||
environment = {
|
||||
NODE_ENV = "production";
|
||||
PORT = builtins.toString cfg.port;
|
||||
LISTEN_ADDR = cfg.listenAddr;
|
||||
} // cfg.environment;
|
||||
|
||||
serviceConfig =
|
||||
{
|
||||
Type = "simple";
|
||||
ExecStart = "${cfg.package}/bin/relay-exporter";
|
||||
DynamicUser = true;
|
||||
StateDirectory = cfg.stateDirectory;
|
||||
WorkingDirectory = "/var/lib/${cfg.stateDirectory}";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 3;
|
||||
}
|
||||
// lib.optionalAttrs (cfg.environmentFile != null) {
|
||||
EnvironmentFile = cfg.environmentFile;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
22
package.json
22
package.json
@ -1,17 +1,27 @@
|
||||
{
|
||||
"name": "relay-exporter",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"description": "Prometheus exporter for Nostr relay health checks",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"test": "tsx --test test/config.test.ts test/smoke.test.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"keywords": [
|
||||
"nostr",
|
||||
"prometheus",
|
||||
"exporter"
|
||||
],
|
||||
"license": "MIT",
|
||||
"packageManager": "pnpm@10.28.0",
|
||||
"dependencies": {
|
||||
"@nostrwatch/nocap": "^0.9.2",
|
||||
"@nostrwatch/nocap-every-adapter-default": "^1.7.0",
|
||||
"nostr-tools": "^2.23.3",
|
||||
"p-limit": "^7.3.0",
|
||||
"prom-client": "^15.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
84
pnpm-lock.yaml
generated
84
pnpm-lock.yaml
generated
@ -11,6 +11,15 @@ importers:
|
||||
'@nostrwatch/nocap':
|
||||
specifier: ^0.9.2
|
||||
version: 0.9.2(@types/node@25.5.0)(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vitest@0.32.4)
|
||||
'@nostrwatch/nocap-every-adapter-default':
|
||||
specifier: ^1.7.0
|
||||
version: 1.7.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)
|
||||
nostr-tools:
|
||||
specifier: ^2.23.3
|
||||
version: 2.23.3(typescript@5.9.3)
|
||||
p-limit:
|
||||
specifier: ^7.3.0
|
||||
version: 7.3.0
|
||||
prom-client:
|
||||
specifier: ^15.1.3
|
||||
version: 15.1.3
|
||||
@ -570,6 +579,18 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@noble/ciphers@2.1.1':
|
||||
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@noble/curves@2.0.1':
|
||||
resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@noble/hashes@2.0.1':
|
||||
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@nostrwatch/logger@0.0.9':
|
||||
resolution: {integrity: sha512-G/Z4E9/vtJhP7bRu0WA8bXE+HiFTLO8bVWCT34RZnW7/W6fWOxoAbTt5fhqrLwzYVtVWcfHwF2fiDidaVhqsQA==}
|
||||
|
||||
@ -603,6 +624,15 @@ packages:
|
||||
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@scure/base@2.0.0':
|
||||
resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==}
|
||||
|
||||
'@scure/bip32@2.0.1':
|
||||
resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==}
|
||||
|
||||
'@scure/bip39@2.0.1':
|
||||
resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==}
|
||||
|
||||
'@sinclair/typebox@0.27.10':
|
||||
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
|
||||
|
||||
@ -1401,6 +1431,17 @@ packages:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
nostr-tools@2.23.3:
|
||||
resolution: {integrity: sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==}
|
||||
peerDependencies:
|
||||
typescript: '>=5.0.0'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
nostr-wasm@0.1.0:
|
||||
resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==}
|
||||
|
||||
npm-run-path@4.0.1:
|
||||
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
|
||||
engines: {node: '>=8'}
|
||||
@ -1428,6 +1469,10 @@ packages:
|
||||
resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
p-limit@7.3.0:
|
||||
resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
p-locate@4.1.0:
|
||||
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||
engines: {node: '>=8'}
|
||||
@ -2465,6 +2510,14 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@noble/ciphers@2.1.1': {}
|
||||
|
||||
'@noble/curves@2.0.1':
|
||||
dependencies:
|
||||
'@noble/hashes': 2.0.1
|
||||
|
||||
'@noble/hashes@2.0.1': {}
|
||||
|
||||
'@nostrwatch/logger@0.0.9':
|
||||
dependencies:
|
||||
logging: 3.3.0
|
||||
@ -2552,6 +2605,19 @@ snapshots:
|
||||
|
||||
'@opentelemetry/api@1.9.0': {}
|
||||
|
||||
'@scure/base@2.0.0': {}
|
||||
|
||||
'@scure/bip32@2.0.1':
|
||||
dependencies:
|
||||
'@noble/curves': 2.0.1
|
||||
'@noble/hashes': 2.0.1
|
||||
'@scure/base': 2.0.0
|
||||
|
||||
'@scure/bip39@2.0.1':
|
||||
dependencies:
|
||||
'@noble/hashes': 2.0.1
|
||||
'@scure/base': 2.0.0
|
||||
|
||||
'@sinclair/typebox@0.27.10': {}
|
||||
|
||||
'@sinonjs/commons@3.0.1':
|
||||
@ -3584,6 +3650,20 @@ snapshots:
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
nostr-tools@2.23.3(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@noble/ciphers': 2.1.1
|
||||
'@noble/curves': 2.0.1
|
||||
'@noble/hashes': 2.0.1
|
||||
'@scure/base': 2.0.0
|
||||
'@scure/bip32': 2.0.1
|
||||
'@scure/bip39': 2.0.1
|
||||
nostr-wasm: 0.1.0
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
nostr-wasm@0.1.0: {}
|
||||
|
||||
npm-run-path@4.0.1:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@ -3610,6 +3690,10 @@ snapshots:
|
||||
dependencies:
|
||||
yocto-queue: 1.2.2
|
||||
|
||||
p-limit@7.3.0:
|
||||
dependencies:
|
||||
yocto-queue: 1.2.2
|
||||
|
||||
p-locate@4.1.0:
|
||||
dependencies:
|
||||
p-limit: 2.3.0
|
||||
|
||||
216
src/config.ts
Normal file
216
src/config.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import { URL } from "node:url";
|
||||
import { generateSecretKey, nip19 } from "nostr-tools";
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
export interface RelayTarget {
|
||||
relay: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
export interface WriteCheckConfig {
|
||||
enabled: boolean;
|
||||
kind: number;
|
||||
privkey?: string;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
relays: RelayTarget[];
|
||||
probeIntervalMs: number;
|
||||
probeTimeoutMs: number;
|
||||
listenAddr: string;
|
||||
port: number;
|
||||
logLevel: LogLevel;
|
||||
probeConcurrency: number;
|
||||
staleAfterMs: number;
|
||||
writeCheck: WriteCheckConfig;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_PROBE_INTERVAL_MS = 0;
|
||||
const DEFAULT_PROBE_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_LISTEN_ADDR = "0.0.0.0";
|
||||
const DEFAULT_PORT = 9464;
|
||||
const DEFAULT_LOG_LEVEL: LogLevel = "info";
|
||||
const DEFAULT_WRITE_CHECK_ENABLED = true;
|
||||
const DEFAULT_WRITE_CHECK_KIND = 30078;
|
||||
|
||||
const MIN_TIMEOUT_MS = 1_000;
|
||||
const MIN_PORT = 1;
|
||||
const MAX_PORT = 65_535;
|
||||
|
||||
// Kept as a constant so operators can tune by editing code.
|
||||
export const DEFAULT_PROBE_CONCURRENCY = 4;
|
||||
|
||||
function parseBoolean(raw: string | undefined, fallback: boolean): boolean {
|
||||
if (raw === undefined || raw.trim() === "") return fallback;
|
||||
const value = raw.trim().toLowerCase();
|
||||
if (["1", "true", "yes", "on"].includes(value)) return true;
|
||||
if (["0", "false", "no", "off"].includes(value)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseInteger(
|
||||
raw: string | undefined,
|
||||
fallback: number,
|
||||
min: number,
|
||||
name: string,
|
||||
): number {
|
||||
if (raw === undefined || raw.trim() === "") return fallback;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || Number.isNaN(parsed)) {
|
||||
throw new Error(`${name} must be an integer`);
|
||||
}
|
||||
if (parsed < min) {
|
||||
throw new Error(`${name} must be >= ${min}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseLogLevel(raw: string | undefined): LogLevel {
|
||||
if (!raw) return DEFAULT_LOG_LEVEL;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (
|
||||
normalized === "debug" ||
|
||||
normalized === "info" ||
|
||||
normalized === "warn" ||
|
||||
normalized === "error"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error("LOG_LEVEL must be one of: debug, info, warn, error");
|
||||
}
|
||||
|
||||
function parseRelay(rawRelay: string): RelayTarget | null {
|
||||
const relayValue = rawRelay.trim();
|
||||
if (!relayValue) return null;
|
||||
let relayUrl: URL;
|
||||
try {
|
||||
relayUrl = new URL(relayValue);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (relayUrl.protocol !== "wss:") return null;
|
||||
return {
|
||||
relay: relayUrl.toString(),
|
||||
host: relayUrl.hostname,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRelays(raw: string): { relays: RelayTarget[]; warnings: string[] } {
|
||||
const warnings: string[] = [];
|
||||
const unique = new Set<string>();
|
||||
const relays: RelayTarget[] = [];
|
||||
|
||||
for (const chunk of raw.split(",")) {
|
||||
const parsed = parseRelay(chunk);
|
||||
if (!parsed) {
|
||||
const clean = chunk.trim();
|
||||
if (clean) warnings.push(`Ignoring invalid relay URL: ${clean}`);
|
||||
continue;
|
||||
}
|
||||
if (unique.has(parsed.relay)) continue;
|
||||
unique.add(parsed.relay);
|
||||
relays.push(parsed);
|
||||
}
|
||||
|
||||
return { relays, warnings };
|
||||
}
|
||||
|
||||
function isHex64(value: string): boolean {
|
||||
return /^[0-9a-f]{64}$/i.test(value);
|
||||
}
|
||||
|
||||
function isValidWriteCheckPrivkey(raw: string): boolean {
|
||||
const value = raw.trim();
|
||||
if (value.startsWith("nsec1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(value);
|
||||
return decoded.type === "nsec";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return isHex64(value);
|
||||
}
|
||||
|
||||
function generateEphemeralWriteCheckPrivkey(): string {
|
||||
return Buffer.from(generateSecretKey()).toString("hex");
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
const relaysRaw = env.RELAYS;
|
||||
if (!relaysRaw || relaysRaw.trim() === "") {
|
||||
throw new Error("RELAYS is required (comma-separated list of wss:// URLs)");
|
||||
}
|
||||
|
||||
const { relays, warnings } = parseRelays(relaysRaw);
|
||||
if (relays.length === 0) {
|
||||
throw new Error("RELAYS did not contain any valid wss:// relay URLs");
|
||||
}
|
||||
|
||||
const probeIntervalMs = parseInteger(
|
||||
env.PROBE_INTERVAL_MS,
|
||||
DEFAULT_PROBE_INTERVAL_MS,
|
||||
0,
|
||||
"PROBE_INTERVAL_MS",
|
||||
);
|
||||
const probeTimeoutMs = parseInteger(
|
||||
env.PROBE_TIMEOUT_MS,
|
||||
DEFAULT_PROBE_TIMEOUT_MS,
|
||||
MIN_TIMEOUT_MS,
|
||||
"PROBE_TIMEOUT_MS",
|
||||
);
|
||||
const port = parseInteger(env.PORT, DEFAULT_PORT, MIN_PORT, "PORT");
|
||||
if (port > MAX_PORT) {
|
||||
throw new Error(`PORT must be <= ${MAX_PORT}`);
|
||||
}
|
||||
|
||||
const writeEnabledFlag = parseBoolean(env.WRITE_CHECK_ENABLED, DEFAULT_WRITE_CHECK_ENABLED);
|
||||
const writeCheckKind = parseInteger(
|
||||
env.WRITE_CHECK_KIND,
|
||||
DEFAULT_WRITE_CHECK_KIND,
|
||||
0,
|
||||
"WRITE_CHECK_KIND",
|
||||
);
|
||||
const writePrivkey = env.WRITE_CHECK_PRIVKEY?.trim();
|
||||
let resolvedWritePrivkey = writePrivkey;
|
||||
if (env.WRITE_CHECK_PUBKEY?.trim()) {
|
||||
warnings.push(
|
||||
"WRITE_CHECK_PUBKEY is ignored; write-check pubkey is always derived from WRITE_CHECK_PRIVKEY",
|
||||
);
|
||||
}
|
||||
if (writeEnabledFlag) {
|
||||
if (!writePrivkey) {
|
||||
resolvedWritePrivkey = generateEphemeralWriteCheckPrivkey();
|
||||
warnings.push(
|
||||
"WRITE_CHECK_PRIVKEY not set; generated ephemeral write-check key for this process",
|
||||
);
|
||||
} else if (!isValidWriteCheckPrivkey(writePrivkey)) {
|
||||
resolvedWritePrivkey = generateEphemeralWriteCheckPrivkey();
|
||||
warnings.push(
|
||||
"WRITE_CHECK_PRIVKEY invalid; generated ephemeral write-check key for this process",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const staleAfterMs = probeIntervalMs + probeTimeoutMs + 5_000;
|
||||
const writeCheck: WriteCheckConfig = {
|
||||
enabled: writeEnabledFlag,
|
||||
kind: writeCheckKind,
|
||||
...(resolvedWritePrivkey ? { privkey: resolvedWritePrivkey } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
relays,
|
||||
probeIntervalMs,
|
||||
probeTimeoutMs,
|
||||
listenAddr: env.LISTEN_ADDR?.trim() || DEFAULT_LISTEN_ADDR,
|
||||
port,
|
||||
logLevel: parseLogLevel(env.LOG_LEVEL),
|
||||
probeConcurrency: DEFAULT_PROBE_CONCURRENCY,
|
||||
staleAfterMs,
|
||||
writeCheck,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
147
src/index.ts
Normal file
147
src/index.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import http from "node:http";
|
||||
import process from "node:process";
|
||||
|
||||
import { loadConfig, type LogLevel } from "./config.js";
|
||||
import { RelayMetrics } from "./metrics.js";
|
||||
import { RelayProber, type Logger } from "./prober.js";
|
||||
|
||||
const LOG_LEVEL_ORDER: Record<LogLevel, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
};
|
||||
|
||||
function sanitizeFields(fields: Record<string, unknown>): Record<string, unknown> {
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (key.toLowerCase().includes("privkey") || key.toLowerCase().includes("secret")) {
|
||||
sanitized[key] = "[redacted]";
|
||||
continue;
|
||||
}
|
||||
sanitized[key] = value;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function createLogger(level: LogLevel): Logger {
|
||||
const minLevel = LOG_LEVEL_ORDER[level];
|
||||
|
||||
const emit = (lvl: LogLevel, fields: Record<string, unknown>, message: string): void => {
|
||||
if (LOG_LEVEL_ORDER[lvl] < minLevel) return;
|
||||
const line = {
|
||||
ts: new Date().toISOString(),
|
||||
level: lvl,
|
||||
msg: message,
|
||||
...sanitizeFields(fields),
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(line)}\n`);
|
||||
};
|
||||
|
||||
return {
|
||||
debug: (fields, message) => emit("debug", fields, message),
|
||||
info: (fields, message) => emit("info", fields, message),
|
||||
warn: (fields, message) => emit("warn", fields, message),
|
||||
error: (fields, message) => emit("error", fields, message),
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const logger = createLogger(config.logLevel);
|
||||
for (const warning of config.warnings) {
|
||||
logger.warn({ component: "config" }, warning);
|
||||
}
|
||||
|
||||
const metrics = new RelayMetrics();
|
||||
const prober = new RelayProber(config, metrics, logger);
|
||||
prober.start();
|
||||
|
||||
let shuttingDown = false;
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (!req.url) {
|
||||
res.statusCode = 400;
|
||||
res.end("bad request");
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/metrics") {
|
||||
if (config.probeIntervalMs <= 0) {
|
||||
await prober.runOnDemand();
|
||||
}
|
||||
const nowUnix = Math.floor(Date.now() / 1000);
|
||||
prober.markStale(nowUnix);
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", metrics.registry.contentType);
|
||||
res.end(await metrics.registry.metrics());
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/healthz") {
|
||||
const nowUnix = Math.floor(Date.now() / 1000);
|
||||
const health = prober.health(nowUnix);
|
||||
const ok = !shuttingDown && health.ok;
|
||||
const body = JSON.stringify(
|
||||
{
|
||||
status: ok ? "ok" : "degraded",
|
||||
shuttingDown,
|
||||
maxLastProbeAgeMs: health.minLastProbeAgeMs,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
res.statusCode = ok ? 200 : 503;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(body);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("not found");
|
||||
});
|
||||
|
||||
const shutdown = (signal: NodeJS.Signals): void => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
logger.info({ signal }, "shutdown requested");
|
||||
prober.stop();
|
||||
|
||||
const forceExitTimer = setTimeout(() => {
|
||||
logger.error({ signal }, "forced shutdown after timeout");
|
||||
process.exit(1);
|
||||
}, 10_000);
|
||||
|
||||
server.close((error?: Error) => {
|
||||
clearTimeout(forceExitTimer);
|
||||
if (error) {
|
||||
logger.error({ error: error.message }, "http server close failed");
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
logger.info({ signal }, "shutdown complete");
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
server.listen(config.port, config.listenAddr, () => {
|
||||
logger.info(
|
||||
{
|
||||
listenAddr: config.listenAddr,
|
||||
port: config.port,
|
||||
relays: config.relays.length,
|
||||
writeCheckEnabled: config.writeCheck.enabled,
|
||||
},
|
||||
"relay exporter started",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : "startup failure";
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
130
src/metrics.ts
Normal file
130
src/metrics.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { Counter, Gauge, Registry, collectDefaultMetrics } from "prom-client";
|
||||
|
||||
export interface RelayProbeUpdate {
|
||||
relay: string;
|
||||
openOk: boolean;
|
||||
readOk: boolean;
|
||||
writeConfirmOk: boolean;
|
||||
up: boolean;
|
||||
openDurationMs: number;
|
||||
readDurationMs: number;
|
||||
writeDurationMs?: number;
|
||||
lastSuccessUnix?: number;
|
||||
}
|
||||
|
||||
export class RelayMetrics {
|
||||
public readonly registry: Registry;
|
||||
|
||||
private readonly relayUp: Gauge<"relay">;
|
||||
private readonly relayOpenOk: Gauge<"relay">;
|
||||
private readonly relayReadOk: Gauge<"relay">;
|
||||
private readonly relayWriteConfirmOk: Gauge<"relay">;
|
||||
private readonly relayOpenDurationMs: Gauge<"relay">;
|
||||
private readonly relayReadDurationMs: Gauge<"relay">;
|
||||
private readonly relayWriteDurationMs: Gauge<"relay">;
|
||||
private readonly relayLastSuccessUnix: Gauge<"relay">;
|
||||
private readonly relayProbeErrorsTotal: Counter<"relay" | "check">;
|
||||
private readonly relayProbeRunsTotal: Counter<"relay" | "result">;
|
||||
|
||||
constructor() {
|
||||
this.registry = new Registry();
|
||||
collectDefaultMetrics({ register: this.registry });
|
||||
|
||||
this.relayUp = new Gauge({
|
||||
name: "nostr_relay_up",
|
||||
help: "1 if open+read(+write confirm when enabled) succeeds, else 0",
|
||||
labelNames: ["relay"],
|
||||
registers: [this.registry],
|
||||
});
|
||||
this.relayOpenOk = new Gauge({
|
||||
name: "nostr_relay_open_ok",
|
||||
help: "Relay open check status",
|
||||
labelNames: ["relay"],
|
||||
registers: [this.registry],
|
||||
});
|
||||
this.relayReadOk = new Gauge({
|
||||
name: "nostr_relay_read_ok",
|
||||
help: "Relay read check status",
|
||||
labelNames: ["relay"],
|
||||
registers: [this.registry],
|
||||
});
|
||||
this.relayWriteConfirmOk = new Gauge({
|
||||
name: "nostr_relay_write_confirm_ok",
|
||||
help: "Relay write+read-after-write confirmation status",
|
||||
labelNames: ["relay"],
|
||||
registers: [this.registry],
|
||||
});
|
||||
this.relayOpenDurationMs = new Gauge({
|
||||
name: "nostr_relay_open_duration_ms",
|
||||
help: "Relay open check duration in milliseconds",
|
||||
labelNames: ["relay"],
|
||||
registers: [this.registry],
|
||||
});
|
||||
this.relayReadDurationMs = new Gauge({
|
||||
name: "nostr_relay_read_duration_ms",
|
||||
help: "Relay read check duration in milliseconds",
|
||||
labelNames: ["relay"],
|
||||
registers: [this.registry],
|
||||
});
|
||||
this.relayWriteDurationMs = new Gauge({
|
||||
name: "nostr_relay_write_duration_ms",
|
||||
help: "Relay write-confirm duration in milliseconds",
|
||||
labelNames: ["relay"],
|
||||
registers: [this.registry],
|
||||
});
|
||||
this.relayLastSuccessUnix = new Gauge({
|
||||
name: "nostr_relay_last_success_unixtime",
|
||||
help: "Unix timestamp of the last successful full probe",
|
||||
labelNames: ["relay"],
|
||||
registers: [this.registry],
|
||||
});
|
||||
this.relayProbeErrorsTotal = new Counter({
|
||||
name: "nostr_relay_probe_errors_total",
|
||||
help: "Total relay probe errors by check",
|
||||
labelNames: ["relay", "check"],
|
||||
registers: [this.registry],
|
||||
});
|
||||
this.relayProbeRunsTotal = new Counter({
|
||||
name: "nostr_relay_probe_runs_total",
|
||||
help: "Total relay probe runs by final result",
|
||||
labelNames: ["relay", "result"],
|
||||
registers: [this.registry],
|
||||
});
|
||||
}
|
||||
|
||||
public initializeRelay(relay: string): void {
|
||||
this.relayUp.labels(relay).set(0);
|
||||
this.relayOpenOk.labels(relay).set(0);
|
||||
this.relayReadOk.labels(relay).set(0);
|
||||
this.relayWriteConfirmOk.labels(relay).set(0);
|
||||
this.relayOpenDurationMs.labels(relay).set(-1);
|
||||
this.relayReadDurationMs.labels(relay).set(-1);
|
||||
this.relayWriteDurationMs.labels(relay).set(-1);
|
||||
this.relayLastSuccessUnix.labels(relay).set(0);
|
||||
}
|
||||
|
||||
public applyUpdate(update: RelayProbeUpdate): void {
|
||||
this.relayUp.labels(update.relay).set(update.up ? 1 : 0);
|
||||
this.relayOpenOk.labels(update.relay).set(update.openOk ? 1 : 0);
|
||||
this.relayReadOk.labels(update.relay).set(update.readOk ? 1 : 0);
|
||||
this.relayWriteConfirmOk.labels(update.relay).set(update.writeConfirmOk ? 1 : 0);
|
||||
this.relayOpenDurationMs.labels(update.relay).set(update.openDurationMs);
|
||||
this.relayReadDurationMs.labels(update.relay).set(update.readDurationMs);
|
||||
this.relayWriteDurationMs.labels(update.relay).set(update.writeDurationMs ?? -1);
|
||||
if (update.lastSuccessUnix !== undefined) {
|
||||
this.relayLastSuccessUnix.labels(update.relay).set(update.lastSuccessUnix);
|
||||
}
|
||||
}
|
||||
|
||||
public markStale(relay: string): void {
|
||||
this.relayUp.labels(relay).set(0);
|
||||
}
|
||||
|
||||
public incProbeError(relay: string, check: string): void {
|
||||
this.relayProbeErrorsTotal.labels(relay, check).inc();
|
||||
}
|
||||
|
||||
public incProbeRun(relay: string, result: "success" | "failure"): void {
|
||||
this.relayProbeRunsTotal.labels(relay, result).inc();
|
||||
}
|
||||
}
|
||||
4
src/nocap-adapters.d.ts
vendored
Normal file
4
src/nocap-adapters.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module "@nostrwatch/nocap-every-adapter-default" {
|
||||
const adapters: Record<string, new (...args: unknown[]) => unknown>;
|
||||
export default adapters;
|
||||
}
|
||||
7
src/nocap.d.ts
vendored
Normal file
7
src/nocap.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
declare module "@nostrwatch/nocap" {
|
||||
export class Nocap {
|
||||
constructor(url: string, config?: unknown);
|
||||
check(keys: string[], headers?: boolean): Promise<unknown>;
|
||||
useAdapters(adapters: unknown[]): Promise<void>;
|
||||
}
|
||||
}
|
||||
296
src/prober.ts
Normal file
296
src/prober.ts
Normal file
@ -0,0 +1,296 @@
|
||||
import pLimit from "p-limit";
|
||||
import { Nocap } from "@nostrwatch/nocap";
|
||||
import NocapEveryAdapterDefault from "@nostrwatch/nocap-every-adapter-default";
|
||||
|
||||
import type { AppConfig, RelayTarget } from "./config.js";
|
||||
import type { RelayMetrics } from "./metrics.js";
|
||||
import { runWriteConfirm } from "./writeConfirm.js";
|
||||
|
||||
export interface Logger {
|
||||
debug(fields: Record<string, unknown>, message: string): void;
|
||||
info(fields: Record<string, unknown>, message: string): void;
|
||||
warn(fields: Record<string, unknown>, message: string): void;
|
||||
error(fields: Record<string, unknown>, message: string): void;
|
||||
}
|
||||
|
||||
interface RelayCheckNode {
|
||||
data?: boolean;
|
||||
duration?: number;
|
||||
status?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface NocapResult {
|
||||
open?: RelayCheckNode;
|
||||
read?: RelayCheckNode;
|
||||
}
|
||||
|
||||
interface RelayProbeState {
|
||||
lastProbeUnix?: number;
|
||||
lastSuccessUnix?: number;
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
promise
|
||||
.then((value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function durationOrDefault(value: number | undefined): number {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
|
||||
return value;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return "unknown error";
|
||||
}
|
||||
|
||||
export class RelayProber {
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private running = false;
|
||||
private rerunRequested = false;
|
||||
private stopped = false;
|
||||
private readonly states = new Map<string, RelayProbeState>();
|
||||
|
||||
constructor(
|
||||
private readonly config: AppConfig,
|
||||
private readonly metrics: RelayMetrics,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
for (const relay of this.config.relays) {
|
||||
this.metrics.initializeRelay(relay.relay);
|
||||
this.states.set(relay.relay, {});
|
||||
}
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
if (this.config.probeIntervalMs <= 0) {
|
||||
this.logger.info(
|
||||
{ probeIntervalMs: this.config.probeIntervalMs },
|
||||
"probe interval disabled; probes run on /metrics scrape",
|
||||
);
|
||||
return;
|
||||
}
|
||||
void this.runCycle();
|
||||
this.timer = setInterval(() => {
|
||||
void this.runCycle();
|
||||
}, this.config.probeIntervalMs);
|
||||
}
|
||||
|
||||
public async runOnDemand(): Promise<void> {
|
||||
await this.runCycle();
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.stopped = true;
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
public markStale(nowUnix: number): void {
|
||||
for (const relay of this.config.relays) {
|
||||
const state = this.states.get(relay.relay);
|
||||
if (!state?.lastProbeUnix) {
|
||||
this.metrics.markStale(relay.relay);
|
||||
continue;
|
||||
}
|
||||
const ageMs = nowUnix * 1000 - state.lastProbeUnix * 1000;
|
||||
if (ageMs > this.config.staleAfterMs) {
|
||||
this.metrics.markStale(relay.relay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public health(nowUnix: number): { ok: boolean; minLastProbeAgeMs: number | null } {
|
||||
let maxAgeMs = 0;
|
||||
let seen = false;
|
||||
for (const relay of this.config.relays) {
|
||||
const state = this.states.get(relay.relay);
|
||||
if (!state?.lastProbeUnix) continue;
|
||||
seen = true;
|
||||
const age = nowUnix * 1000 - state.lastProbeUnix * 1000;
|
||||
if (age > maxAgeMs) maxAgeMs = age;
|
||||
}
|
||||
if (!seen) return { ok: false, minLastProbeAgeMs: null };
|
||||
return { ok: maxAgeMs <= this.config.staleAfterMs, minLastProbeAgeMs: maxAgeMs };
|
||||
}
|
||||
|
||||
private async runCycle(): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
if (this.running) {
|
||||
this.rerunRequested = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
const cycleStarted = Date.now();
|
||||
this.logger.debug(
|
||||
{
|
||||
relayCount: this.config.relays.length,
|
||||
concurrency: this.config.probeConcurrency,
|
||||
},
|
||||
"probe cycle started",
|
||||
);
|
||||
|
||||
try {
|
||||
const limit = pLimit(this.config.probeConcurrency);
|
||||
await Promise.allSettled(
|
||||
this.config.relays.map((relay) =>
|
||||
limit(async () => {
|
||||
await this.probeRelay(relay);
|
||||
}),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
this.running = false;
|
||||
this.logger.debug(
|
||||
{ durationMs: Date.now() - cycleStarted, rerunRequested: this.rerunRequested },
|
||||
"probe cycle finished",
|
||||
);
|
||||
if (this.rerunRequested && !this.stopped) {
|
||||
this.rerunRequested = false;
|
||||
void this.runCycle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async probeRelay(relay: RelayTarget): Promise<void> {
|
||||
const relayLabel = relay.relay;
|
||||
const probeStarted = Date.now();
|
||||
const state = this.states.get(relayLabel) ?? {};
|
||||
|
||||
let openOk = false;
|
||||
let readOk = false;
|
||||
let writeConfirmOk = !this.config.writeCheck.enabled;
|
||||
let openDurationMs = -1;
|
||||
let readDurationMs = -1;
|
||||
let writeDurationMs = -1;
|
||||
|
||||
try {
|
||||
const nocap = new Nocap(relay.relay, {
|
||||
logLevel: this.config.logLevel,
|
||||
timeout: {
|
||||
open: this.config.probeTimeoutMs,
|
||||
read: this.config.probeTimeoutMs,
|
||||
},
|
||||
failAllChecksOnConnectFailure: true,
|
||||
});
|
||||
const adapters = Object.values(NocapEveryAdapterDefault);
|
||||
await nocap.useAdapters(adapters);
|
||||
|
||||
const result = (await withTimeout(
|
||||
nocap.check(["open", "read"], true) as Promise<NocapResult>,
|
||||
this.config.probeTimeoutMs * 2,
|
||||
"nocap open/read",
|
||||
)) as NocapResult;
|
||||
|
||||
openOk = Boolean(result.open?.data);
|
||||
readOk = Boolean(result.read?.data);
|
||||
openDurationMs = durationOrDefault(result.open?.duration);
|
||||
readDurationMs = durationOrDefault(result.read?.duration);
|
||||
|
||||
if (!openOk) {
|
||||
this.metrics.incProbeError(relayLabel, "open");
|
||||
}
|
||||
if (!readOk) {
|
||||
this.metrics.incProbeError(relayLabel, "read");
|
||||
}
|
||||
|
||||
if (this.config.writeCheck.enabled) {
|
||||
const privkey = this.config.writeCheck.privkey;
|
||||
if (!privkey) {
|
||||
writeConfirmOk = false;
|
||||
this.metrics.incProbeError(relayLabel, "write_confirm");
|
||||
} else {
|
||||
const writeResult = await runWriteConfirm({
|
||||
relay: relay.relay,
|
||||
host: relay.host,
|
||||
kind: this.config.writeCheck.kind,
|
||||
privkey,
|
||||
timeoutMs: this.config.probeTimeoutMs,
|
||||
});
|
||||
writeConfirmOk = writeResult.ok;
|
||||
writeDurationMs = durationOrDefault(writeResult.durationMs);
|
||||
if (!writeResult.ok) {
|
||||
this.metrics.incProbeError(relayLabel, "write_confirm");
|
||||
this.logger.warn(
|
||||
{ relay: relayLabel, check: "write_confirm", reason: writeResult.reason },
|
||||
"write confirmation failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const up = openOk && readOk && writeConfirmOk;
|
||||
const nowUnix = Math.floor(Date.now() / 1000);
|
||||
state.lastProbeUnix = nowUnix;
|
||||
if (up) {
|
||||
state.lastSuccessUnix = nowUnix;
|
||||
this.metrics.incProbeRun(relayLabel, "success");
|
||||
} else {
|
||||
this.metrics.incProbeRun(relayLabel, "failure");
|
||||
}
|
||||
this.states.set(relayLabel, state);
|
||||
|
||||
this.metrics.applyUpdate({
|
||||
relay: relayLabel,
|
||||
openOk,
|
||||
readOk,
|
||||
writeConfirmOk,
|
||||
up,
|
||||
openDurationMs,
|
||||
readDurationMs,
|
||||
writeDurationMs,
|
||||
...(state.lastSuccessUnix !== undefined ? { lastSuccessUnix: state.lastSuccessUnix } : {}),
|
||||
});
|
||||
|
||||
this.logger.info(
|
||||
{
|
||||
relay: relayLabel,
|
||||
up,
|
||||
openOk,
|
||||
readOk,
|
||||
writeConfirmOk,
|
||||
durationMs: Date.now() - probeStarted,
|
||||
},
|
||||
"relay probe completed",
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const nowUnix = Math.floor(Date.now() / 1000);
|
||||
state.lastProbeUnix = nowUnix;
|
||||
this.states.set(relayLabel, state);
|
||||
|
||||
this.metrics.incProbeError(relayLabel, "probe");
|
||||
this.metrics.incProbeRun(relayLabel, "failure");
|
||||
this.metrics.applyUpdate({
|
||||
relay: relayLabel,
|
||||
openOk: false,
|
||||
readOk: false,
|
||||
writeConfirmOk: false,
|
||||
up: false,
|
||||
openDurationMs,
|
||||
readDurationMs,
|
||||
writeDurationMs,
|
||||
...(state.lastSuccessUnix !== undefined ? { lastSuccessUnix: state.lastSuccessUnix } : {}),
|
||||
});
|
||||
this.logger.error(
|
||||
{ relay: relayLabel, error: messageOf(error), durationMs: Date.now() - probeStarted },
|
||||
"relay probe failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
142
src/writeConfirm.ts
Normal file
142
src/writeConfirm.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { finalizeEvent, getPublicKey, nip19, Relay, type Filter } from "nostr-tools";
|
||||
|
||||
interface WriteConfirmInput {
|
||||
relay: string;
|
||||
host: string;
|
||||
kind: number;
|
||||
privkey: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface WriteConfirmResult {
|
||||
ok: boolean;
|
||||
durationMs: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function isHex64(value: string): boolean {
|
||||
return /^[0-9a-f]{64}$/i.test(value);
|
||||
}
|
||||
|
||||
function decodePrivateKey(raw: string): Uint8Array {
|
||||
const value = raw.trim();
|
||||
if (value.startsWith("nsec1")) {
|
||||
const decoded = nip19.decode(value);
|
||||
if (decoded.type !== "nsec") {
|
||||
throw new Error("WRITE_CHECK_PRIVKEY is not a valid nsec key");
|
||||
}
|
||||
return decoded.data;
|
||||
}
|
||||
if (!isHex64(value)) {
|
||||
throw new Error("WRITE_CHECK_PRIVKEY must be nsec or 64-char hex");
|
||||
}
|
||||
return Uint8Array.from(Buffer.from(value, "hex"));
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
promise
|
||||
.then((value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForEvent(
|
||||
relay: Relay,
|
||||
filter: Filter,
|
||||
expectedEventId: string,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
return withTimeout<boolean>(
|
||||
new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
const sub = relay.subscribe([filter], {
|
||||
onevent: (evt) => {
|
||||
if (settled) return;
|
||||
if (evt.id === expectedEventId) {
|
||||
settled = true;
|
||||
sub.close();
|
||||
resolve(true);
|
||||
}
|
||||
},
|
||||
oneose: () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
sub.close();
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
}),
|
||||
timeoutMs,
|
||||
"write confirm read-after-write",
|
||||
);
|
||||
}
|
||||
|
||||
export async function runWriteConfirm(input: WriteConfirmInput): Promise<WriteConfirmResult> {
|
||||
const startedAt = performance.now();
|
||||
let relay: Relay | null = null;
|
||||
try {
|
||||
const secretKey = decodePrivateKey(input.privkey);
|
||||
const derivedPubkey = getPublicKey(secretKey);
|
||||
|
||||
relay = await withTimeout(
|
||||
Relay.connect(input.relay, { enablePing: false, enableReconnect: false }),
|
||||
input.timeoutMs,
|
||||
"relay connect",
|
||||
);
|
||||
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
const dTag = `healthcheck:${input.host}`;
|
||||
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: input.kind,
|
||||
created_at: createdAt,
|
||||
tags: [["d", dTag]],
|
||||
content: "ok",
|
||||
},
|
||||
secretKey,
|
||||
);
|
||||
|
||||
await withTimeout(relay.publish(event), input.timeoutMs, "relay publish");
|
||||
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
const remainingMs = Math.max(1, input.timeoutMs - Math.floor(elapsedMs));
|
||||
|
||||
const confirmed = await waitForEvent(
|
||||
relay,
|
||||
{
|
||||
authors: [derivedPubkey],
|
||||
kinds: [input.kind],
|
||||
"#d": [dTag],
|
||||
limit: 1,
|
||||
since: Math.max(0, createdAt - 5),
|
||||
},
|
||||
event.id,
|
||||
remainingMs,
|
||||
);
|
||||
|
||||
return {
|
||||
ok: confirmed,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
...(confirmed ? {} : { reason: "event not observed on read-after-write query" }),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "unknown write confirmation error";
|
||||
return {
|
||||
ok: false,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
reason: message,
|
||||
};
|
||||
} finally {
|
||||
relay?.close();
|
||||
}
|
||||
}
|
||||
48
test/config.test.ts
Normal file
48
test/config.test.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
test("loadConfig parses valid relays and ignores invalid entries", () => {
|
||||
const config = loadConfig({
|
||||
RELAYS: "wss://relay.damus.io,not-a-url,wss://nos.lol",
|
||||
WRITE_CHECK_ENABLED: "false",
|
||||
});
|
||||
|
||||
assert.equal(config.relays.length, 2);
|
||||
assert.equal(config.relays[0]?.relay, "wss://relay.damus.io/");
|
||||
assert.equal(config.relays[1]?.relay, "wss://nos.lol/");
|
||||
assert.equal(config.writeCheck.enabled, false);
|
||||
assert.equal(config.probeIntervalMs, 0);
|
||||
assert.ok(config.warnings.some((w) => w.includes("Ignoring invalid relay URL")));
|
||||
});
|
||||
|
||||
test("loadConfig generates write-check private key when missing", () => {
|
||||
const config = loadConfig({
|
||||
RELAYS: "wss://relay.damus.io",
|
||||
WRITE_CHECK_ENABLED: "true",
|
||||
});
|
||||
|
||||
assert.equal(config.writeCheck.enabled, true);
|
||||
assert.match(config.writeCheck.privkey ?? "", /^[0-9a-f]{64}$/i);
|
||||
assert.ok(
|
||||
config.warnings.some((w) => w.includes("generated ephemeral write-check key")),
|
||||
"expected warning about generated write-check key",
|
||||
);
|
||||
});
|
||||
|
||||
test("loadConfig generates write-check private key when configured key is invalid", () => {
|
||||
const config = loadConfig({
|
||||
RELAYS: "wss://relay.damus.io",
|
||||
WRITE_CHECK_ENABLED: "true",
|
||||
WRITE_CHECK_PRIVKEY: "not-a-valid-key",
|
||||
});
|
||||
|
||||
assert.equal(config.writeCheck.enabled, true);
|
||||
assert.match(config.writeCheck.privkey ?? "", /^[0-9a-f]{64}$/i);
|
||||
assert.notEqual(config.writeCheck.privkey, "not-a-valid-key");
|
||||
assert.ok(
|
||||
config.warnings.some((w) => w.includes("WRITE_CHECK_PRIVKEY invalid")),
|
||||
"expected warning about invalid configured key",
|
||||
);
|
||||
});
|
||||
109
test/smoke.test.ts
Normal file
109
test/smoke.test.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import http from "node:http";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
function randomPort(): number {
|
||||
return 20_000 + Math.floor(Math.random() * 10_000);
|
||||
}
|
||||
|
||||
function httpGet(
|
||||
port: number,
|
||||
path: string,
|
||||
): Promise<{ statusCode: number; body: string; headers: http.IncomingHttpHeaders }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path,
|
||||
method: "GET",
|
||||
timeout: 4_000,
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
statusCode: res.statusCode ?? 0,
|
||||
body: Buffer.concat(chunks).toString("utf8"),
|
||||
headers: res.headers,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("timeout", () => {
|
||||
req.destroy(new Error(`timeout GET ${path}`));
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForStartup(child: ChildProcessWithoutNullStreams, port: number, timeoutMs: number): Promise<void> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`exporter exited early with code ${child.exitCode}`);
|
||||
}
|
||||
try {
|
||||
const res = await httpGet(port, "/healthz");
|
||||
if ([200, 503].includes(res.statusCode)) return;
|
||||
} catch {
|
||||
// server not up yet
|
||||
}
|
||||
await sleep(125);
|
||||
}
|
||||
throw new Error("timeout waiting for exporter startup");
|
||||
}
|
||||
|
||||
test("exporter serves healthz and metrics without crashing", async (t) => {
|
||||
const port = randomPort();
|
||||
const child = spawn(
|
||||
"pnpm",
|
||||
["exec", "tsx", "src/index.ts"],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
RELAYS: "wss://relay.damus.io,wss://nos.lol",
|
||||
WRITE_CHECK_ENABLED: "false",
|
||||
PROBE_TIMEOUT_MS: "2000",
|
||||
PROBE_INTERVAL_MS: "3000",
|
||||
PORT: String(port),
|
||||
LOG_LEVEL: "error",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (d: Buffer) => {
|
||||
stderr += d.toString("utf8");
|
||||
});
|
||||
|
||||
t.after(() => {
|
||||
if (child.exitCode === null) {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
});
|
||||
|
||||
await waitForStartup(child, port, 8000);
|
||||
|
||||
const health = await httpGet(port, "/healthz");
|
||||
assert.ok([200, 503].includes(health.statusCode), `unexpected /healthz status ${health.statusCode}`);
|
||||
assert.match(health.body, /"status": "(ok|degraded)"/);
|
||||
|
||||
const metrics = await httpGet(port, "/metrics");
|
||||
assert.equal(metrics.statusCode, 200);
|
||||
assert.match(metrics.body, /nostr_relay_up/);
|
||||
assert.match(metrics.body, /nostr_relay_open_ok/);
|
||||
assert.match(metrics.body, /nostr_relay_probe_runs_total/);
|
||||
assert.match(metrics.body, /process_cpu_user_seconds_total|nodejs_eventloop_lag_seconds/);
|
||||
|
||||
await sleep(3500);
|
||||
assert.equal(child.exitCode, null, `exporter crashed unexpectedly: ${stderr}`);
|
||||
});
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user