clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

observe: each relay writes a meter line per usage flush and a data point when a dataset is bound, and the worker exports logs and traces over OTLP, since the platform has no process to scrape

Dami committed 6f51f92
commit
6f51f921c4f972ec5449aa74fb1b2e4af8cc2d33
parent
b143f053edbecd7564ba2738ca96587d98f8da80
author
Dami <dami.etoile@gmail.com>
3 files changed, +63 -5
Mdocs/11-hosting-bindws.md26++++++++++++++++++++++++++
Msrc/relay.ts28++++++++++++++++++++++++----
Mwrangler.jsonc14+++++++++++++-
M docs/11-hosting-bindws.md +26 -0
@@ -57,6 +57,32 @@ Nothing else is required: no origin, no certificates, no servers. An idle relay
5757 - The `usage` table grows one row per relay per month. No cleanup is needed.5858 - To take a relay down as the operator, use the owner's typed-name `deleterelay` call or the console.5959 60+## Observe61+62+Nothing runs between requests, so there is no process to scrape. What the platform offers instead is logs and traces, exported to any OpenTelemetry endpoint, and a metrics store queried with SQL.63+64+**The meter line.** Every relay writes one JSON line to the console each time it flushes its usage counters: on a socket closing, on the daily alarm, and whenever the counters pass a threshold in between. The line is `{ "msg": "meter", "relay": <name>, "bytesIn", "bytesOut", "rowsRead", "rowsWritten", "activeMs", "accepted", "refused", "reqs", "connections" }`. All but the last are deltas since the previous line; a destination sums them per relay. `accepted` and `refused` count events answered with an OK, `reqs` counts REQ and COUNT messages, `activeMs` is the awake time fuel meters. `wrangler tail` shows the lines live, and the Workers Logs page keeps them for the plan's retention.65+66+**Logs and traces elsewhere.** In the dashboard, Workers Observability, add a destination of type Logs and one of type Traces with the provider's OTLP endpoint and its auth header. Then name them in `wrangler.jsonc`:67+68+```69+"observability": {70+ "enabled": true,71+ "logs": { "enabled": true, "destinations": ["my-logs"] },72+ "traces": { "enabled": true, "head_sampling_rate": 0.1, "destinations": ["my-traces"] }73+}74+```75+76+Traces cover the Worker's own fetches and binding calls, the Durable Object invocations included; there are no custom spans yet. `persist: false` on either sends it out without keeping a copy in the dashboard. Export needs Workers Paid; Cloudflare bills it per event from October 2026, ten million a month included. Metrics cannot be exported this way yet, which is why the meter line exists: a Grafana or Honeycomb query over it gives per-relay rates.77+78+**Analytics Engine.** For a metrics store on Cloudflare itself, bind a dataset and every flush also writes one data point:79+80+```81+"analytics_engine_datasets": [{ "binding": "METRICS", "dataset": "bindws_relays" }]82+```83+84+The point's index is the relay's name, `blob1` is `meter`, and `double1` to `double9` are the meter line's numbers in the same order. Query it with the SQL API or the Grafana plugin, for example `SELECT index1 AS relay, sum(double6) AS accepted FROM bindws_relays WHERE timestamp > now() - interval '1' day GROUP BY relay`. Ten million points a month are included on Workers Paid; without the binding nothing is written.85+6086 ## Custom domains6187 6288 Owners can put a relay under a hostname they control, such as `relay.example.com`, through [Cloudflare for SaaS custom hostnames](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/start/getting-started/). The feature is off until the one-time setup below is done; the console says so until then.
M src/relay.ts +24 -4
@@ -63,6 +63,9 @@ export interface Env {
6363 SATS_PER_GB_MONTH_MEDIA?: string;6464 SATS_PER_ACTIVE_HOUR?: string;6565 SATS_PER_MILLION_ROWS?: string;66+ // Metrics (optional): a Workers Analytics Engine dataset that takes one67+ // data point per usage flush, next to the meter log line (flushUsage).68+ METRICS?: AnalyticsEngineDataset;6669 }6770 6871 // The less obvious numbers: 43 is added by info() once the relay has an
@@ -128,8 +131,10 @@ export class Relay extends DurableObject<Env> {
128131 private buckets = new Map<WebSocket, { events: Bucket; reqs: Bucket }>();129132 // Per-address buckets, in memory only: they reset when the object sleeps.130133 private ipBuckets = new Map<string, { events: Bucket; reqs: Bucket }>();131- // Usage counters, flushed to the usage table in batches.132- private meter = { bytesIn: 0, bytesOut: 0, rowsRead: 0, rowsWritten: 0, activeMs: 0 };134+ // Usage counters, flushed to the usage table in batches. The last three135+ // are for the meter log line only: events accepted, events refused with136+ // an OK false, and REQ or COUNT messages served.137+ private meter = { bytesIn: 0, bytesOut: 0, rowsRead: 0, rowsWritten: 0, activeMs: 0, accepted: 0, refused: 0, reqs: 0 };133138 // When the object last did work, for the active-time meter (ms). Zero after a wake.134139 private lastActive = 0;135140 private lnurl: LnurlParams | null = null;
@@ -524,7 +529,7 @@ export class Relay extends DurableObject<Env> {
524529 this.syncs.clear();525530 this.buckets.clear();526531 this.ipBuckets.clear();527- this.meter = { bytesIn: 0, bytesOut: 0, rowsRead: 0, rowsWritten: 0, activeMs: 0 };532+ this.meter = { bytesIn: 0, bytesOut: 0, rowsRead: 0, rowsWritten: 0, activeMs: 0, accepted: 0, refused: 0, reqs: 0 };528533 this.lnurl = null;529534 this.store = new Store(this.sql);530535 this.settings = new Settings(this.sql);
@@ -836,12 +841,24 @@ export class Relay extends DurableObject<Env> {
836841 if (this.meter.bytesIn + this.meter.bytesOut > FLUSH_BYTES || this.meter.rowsRead + this.meter.rowsWritten > 5000) this.flushUsage();837842 }838843 844+ // flushUsage moves the counters to the usage table, and reports the same845+ // numbers outward: one JSON line on the console, which Workers Logs and846+ // any OTLP logs destination carry with the relay's name in it, and one847+ // Analytics Engine data point when the host bound a dataset. Both are848+ // deltas since the last flush; a destination sums them.839849 flushUsage() {840850 const m = this.meter;841851 if (m.bytesIn + m.bytesOut + m.rowsRead + m.rowsWritten + m.activeMs === 0) return;842- this.meter = { bytesIn: 0, bytesOut: 0, rowsRead: 0, rowsWritten: 0, activeMs: 0 };852+ this.meter = { bytesIn: 0, bytesOut: 0, rowsRead: 0, rowsWritten: 0, activeMs: 0, accepted: 0, refused: 0, reqs: 0 };843853 this.fuel.record(now(), m);844854 this.store.drain();855+ const connections = this.connections();856+ console.log(JSON.stringify({ msg: "meter", relay: this.slug, ...m, connections }));857+ this.env.METRICS?.writeDataPoint({858+ indexes: [this.slug],859+ blobs: ["meter"],860+ doubles: [m.bytesIn, m.bytesOut, m.rowsRead, m.rowsWritten, m.activeMs, m.accepted, m.refused, m.reqs, connections],861+ });845862 }846863 847864 // Events live in the object's SQLite database; media in R2. Priced apart.
@@ -1201,6 +1218,7 @@ export class Relay extends DurableObject<Env> {
12011218 if (arr.length < 3) return this.send(ws, "NOTICE", `error: ${typ} needs an id and at least one filter`);12021219 const limited = this.bucket(ws).reqs.take(p.reqsPerMinute) || this.ipLimit(s.ip, "reqs");12031220 if (limited) return this.send(ws, "CLOSED", typeof arr[1] === "string" ? arr[1] : "", limited);1221+ this.meter.reqs++;12041222 return this.handleReq(ws, s, typ, arr[1], arr.slice(2));12051223 }12061224 case "CLOSE":
@@ -1246,6 +1264,8 @@ export class Relay extends DurableObject<Env> {
12461264 return;12471265 }12481266 const r = await this.acceptAny(e, s);1267+ if (r.ok) this.meter.accepted++;1268+ else this.meter.refused++;12491269 this.send(ws, "OK", e.id, r.ok, r.msg);12501270 if (r.stored) this.broadcast(e);12511271 }
M wrangler.jsonc +13 -1