Implement exporter using nocap from nostr-watch along with tests

This commit is contained in:
pleb 2026-03-22 14:55:22 -07:00
parent 9c6aef1d92
commit 0531a23680
17 changed files with 1576 additions and 7 deletions

17
.env.example Normal file
View 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
View File

@ -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
View 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
View 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
View 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;
};
};
};
};
};
}

View File

@ -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
View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}