Add relay health panel and telemetry opt-in

This commit is contained in:
thePR0M3TH3AN committed 2026-01-19 14:03:15 -05:00
1 parent 6545fb201b
commit 6116b6a7db
6 files changed
+527 -6

No files matched your search

+38
View File
@@ -664,6 +664,44 @@
</div>
</div>
<ul id="relayList" class="grid-stack" data-orientation="vertical"></ul>
<div id="relayHealthPanel" class="card space-y-4 p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<h3 class="text-base font-semibold text-text">Relay health</h3>
<p class="text-sm text-muted">
Monitor connectivity, latency, and errors for your configured relays.
</p>
</div>
<button
id="relayHealthRefreshBtn"
type="button"
class="btn-ghost focus-ring text-xs"
>
Refresh
</button>
</div>
<label
for="relayHealthTelemetryOptIn"
class="inline-flex items-center gap-2 text-xs font-medium text-muted-strong"
>
<input
id="relayHealthTelemetryOptIn"
type="checkbox"
class="checkbox"
/>
Share relay health telemetry
</label>
<p class="text-xs text-muted">
Telemetry is opt-in and only includes relay URLs, latency, and error counts.
</p>
<ul id="relayHealthList" class="grid-stack" data-orientation="vertical"></ul>
<p
id="relayHealthStatus"
class="text-xs text-muted"
role="status"
aria-live="polite"
></p>
</div>
</section>
<section
+10 -6
View File
@@ -116,6 +116,10 @@ import {
decryptDmInWorker,
isDmDecryptWorkerSupported,
} from "./dmDecryptWorkerClient.js";
import {
sanitizeDecryptError,
summarizeDmEventForLog,
} from "./dmDecryptDiagnostics.js";
import { devLogger, userLogger } from "../utils/logger.js";
import { LRUCache } from "../utils/lruCache.js";
import { updateConversationFromMessage, writeMessages } from "../storage/dmDb.js";
@@ -4177,8 +4181,8 @@ export class NostrClient {
result = await decryptDM(event, context);
} catch (error) {
devLogger.warn("[nostr] DM decryptor threw unexpectedly.", {
error,
eventId,
error: sanitizeDecryptError(error),
event: summarizeDmEventForLog(event),
});
throw error;
}
@@ -4262,8 +4266,8 @@ export class NostrClient {
}
} catch (error) {
devLogger.warn("[nostr] Failed to decrypt DM event during list.", {
error,
id: event?.id || null,
error: sanitizeDecryptError(error),
event: summarizeDmEventForLog(event),
});
}
}
@@ -4349,8 +4353,8 @@ export class NostrClient {
devLogger.warn(
"[nostr] Failed to decrypt DM event from subscription.",
{
error,
id: eventId,
error: sanitizeDecryptError(error),
event: summarizeDmEventForLog(event),
},
);
}
+42
View File
@@ -0,0 +1,42 @@
export function summarizeDmEventForLog(event) {
if (!event || typeof event !== "object") {
return {
kind: null,
createdAt: null,
hasContent: false,
contentLength: 0,
tagCount: 0,
};
}
const createdAt = Number.isFinite(event.created_at)
? Math.floor(event.created_at)
: null;
return {
kind: Number.isFinite(event.kind) ? event.kind : null,
createdAt,
hasContent: typeof event.content === "string" && event.content.length > 0,
contentLength: typeof event.content === "string" ? event.content.length : 0,
tagCount: Array.isArray(event.tags) ? event.tags.length : 0,
};
}
export function sanitizeDecryptError(error) {
if (!error) {
return null;
}
const message =
typeof error.message === "string"
? error.message
: typeof error === "string"
? error
: "";
return {
name: typeof error.name === "string" ? error.name : "",
code: typeof error.code === "string" ? error.code : "",
message,
};
}
+236
View File
@@ -0,0 +1,236 @@
import { devLogger, userLogger } from "../utils/logger.js";
const TELEMETRY_STORAGE_KEY = "bitvid:relay-health-telemetry-opt-in";
function resolveLogger(logger) {
if (logger && logger.dev && logger.user) {
return logger;
}
return { dev: devLogger, user: userLogger };
}
function nowMs() {
return typeof performance !== "undefined" && performance.now
? performance.now()
: Date.now();
}
function readTelemetryOptIn() {
if (typeof window === "undefined") {
return false;
}
try {
const stored = window.localStorage.getItem(TELEMETRY_STORAGE_KEY);
return stored === "true";
} catch (error) {
userLogger.warn(
"[relayHealth] Failed to read telemetry preference from storage.",
error,
);
return false;
}
}
function writeTelemetryOptIn(nextValue) {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(
TELEMETRY_STORAGE_KEY,
nextValue ? "true" : "false",
);
} catch (error) {
userLogger.warn(
"[relayHealth] Failed to persist telemetry preference.",
error,
);
}
}
class RelayHealthService {
constructor({ relayManager, nostrClient, logger, telemetryEmitter } = {}) {
this.relayManager = relayManager || null;
this.nostrClient = nostrClient || null;
this.logger = resolveLogger(logger);
this.telemetryEmitter =
typeof telemetryEmitter === "function" ? telemetryEmitter : null;
this.relayStates = new Map();
this.attachedRelays = new Set();
this.cachedTelemetryOptIn = readTelemetryOptIn();
}
getTelemetryOptIn() {
return this.cachedTelemetryOptIn;
}
setTelemetryOptIn(value) {
const normalized = Boolean(value);
this.cachedTelemetryOptIn = normalized;
writeTelemetryOptIn(normalized);
return normalized;
}
getRelayUrls() {
if (!this.relayManager || typeof this.relayManager.getEntries !== "function") {
return [];
}
const entries = this.relayManager.getEntries();
return entries
.map((entry) => (typeof entry?.url === "string" ? entry.url.trim() : ""))
.filter(Boolean);
}
ensureRelayState(relayUrl) {
if (!this.relayStates.has(relayUrl)) {
this.relayStates.set(relayUrl, {
url: relayUrl,
connected: false,
lastLatencyMs: null,
errorCount: 0,
lastCheckedAt: null,
lastErrorAt: null,
});
}
return this.relayStates.get(relayUrl);
}
attachRelayListeners(relayUrl, relay) {
if (!relay || typeof relay.on !== "function") {
return;
}
if (this.attachedRelays.has(relayUrl)) {
return;
}
try {
relay.on("connect", () => {
this.recordRelaySuccess(relayUrl, null);
});
relay.on("disconnect", () => {
this.recordRelayFailure(relayUrl, new Error("relay-disconnect"));
});
relay.on("error", (error) => {
this.recordRelayFailure(relayUrl, error);
});
this.attachedRelays.add(relayUrl);
} catch (error) {
this.logger.dev.warn(
"[relayHealth] Failed to attach relay listeners.",
error,
);
}
}
recordRelaySuccess(relayUrl, latencyMs) {
const state = this.ensureRelayState(relayUrl);
state.connected = true;
state.lastCheckedAt = Date.now();
if (Number.isFinite(latencyMs)) {
state.lastLatencyMs = Math.max(0, Math.round(latencyMs));
}
}
recordRelayFailure(relayUrl, error) {
const state = this.ensureRelayState(relayUrl);
state.connected = false;
state.errorCount += 1;
state.lastCheckedAt = Date.now();
state.lastErrorAt = Date.now();
this.logger.dev.warn("[relayHealth] Relay check failed.", {
relayUrl,
error,
});
}
async checkRelay(relayUrl) {
if (!relayUrl) {
return;
}
if (!this.nostrClient) {
this.recordRelayFailure(relayUrl, new Error("nostr-client-unavailable"));
return;
}
try {
if (typeof this.nostrClient.ensurePool === "function") {
await this.nostrClient.ensurePool();
}
} catch (error) {
this.recordRelayFailure(relayUrl, error);
return;
}
if (!this.nostrClient.pool || typeof this.nostrClient.pool.ensureRelay !== "function") {
this.recordRelayFailure(relayUrl, new Error("relay-pool-unavailable"));
return;
}
const start = nowMs();
try {
const relay = await this.nostrClient.pool.ensureRelay(relayUrl);
if (!relay) {
throw new Error("relay-unavailable");
}
this.attachRelayListeners(relayUrl, relay);
this.recordRelaySuccess(relayUrl, nowMs() - start);
} catch (error) {
this.recordRelayFailure(relayUrl, error);
}
}
getSnapshot(relayUrls = null) {
const urls = Array.isArray(relayUrls) ? relayUrls : this.getRelayUrls();
return urls.map((url) => ({ ...this.ensureRelayState(url) }));
}
async refresh(relayUrls = null) {
const urls = Array.isArray(relayUrls) ? relayUrls : this.getRelayUrls();
if (!urls.length) {
return [];
}
await Promise.allSettled(urls.map((url) => this.checkRelay(url)));
const snapshot = this.getSnapshot(urls);
this.emitTelemetry(snapshot);
return snapshot;
}
emitTelemetry(snapshot) {
if (!this.telemetryEmitter || !this.getTelemetryOptIn()) {
return;
}
const payload = {
sampledAt: Date.now(),
relays: Array.isArray(snapshot)
? snapshot.map((entry) => ({
url: entry.url,
connected: Boolean(entry.connected),
latencyMs:
Number.isFinite(entry.lastLatencyMs) ? entry.lastLatencyMs : null,
errorCount: Number.isFinite(entry.errorCount)
? entry.errorCount
: 0,
}))
: [],
};
try {
this.telemetryEmitter("relay_health_snapshot", payload);
} catch (error) {
this.logger.user.warn(
"[relayHealth] Failed to emit relay health telemetry.",
error,
);
}
}
}
export default RelayHealthService;
+8
View File
@@ -17,6 +17,7 @@ import nostrService from "../services/nostrService.js";
import storageService from "../services/storageService.js";
import watchHistoryService from "../watchHistoryService.js";
import r2Service from "../services/r2Service.js";
import RelayHealthService from "../services/relayHealthService.js";
import { createFeedEngine } from "../feedEngine/index.js";
import { URL_FIRST_ENABLED } from "../constants.js";
import { ALLOW_NSFW_CONTENT } from "../config.js";
@@ -426,6 +427,12 @@ export default class ApplicationBootstrap {
const modalContainer = doc?.getElementById("modalContainer") || null;
try {
if (modalContainer) {
const relayHealthService = new RelayHealthService({
relayManager,
nostrClient,
telemetryEmitter: (eventName, payload) =>
app.emitTelemetryEvent(eventName, payload),
});
const profileModalServices = {
normalizeHexPubkey: (value) => app.normalizeHexPubkey(value),
safeEncodeNpub: (pubkey) => app.safeEncodeNpub(pubkey),
@@ -480,6 +487,7 @@ export default class ApplicationBootstrap {
app.describeHashtagPreferencesError(error, {
fallbackMessage,
}),
relayHealthService,
log: (...args) => app.log(...args),
closeAllMoreMenus: (options) => app.closeAllMoreMenus(options),
clipboard:
+193
View File
@@ -1241,6 +1241,12 @@ export class ProfileModalController {
this.relayInput = null;
this.addRelayButton = null;
this.restoreRelaysButton = null;
this.relayHealthPanel = null;
this.relayHealthList = null;
this.relayHealthStatus = null;
this.relayHealthRefreshButton = null;
this.relayHealthTelemetryToggle = null;
this.relayHealthRefreshPromise = null;
this.profileRelayList = null;
this.profileRelayInput = null;
this.profileAddRelayBtn = null;
@@ -1521,6 +1527,16 @@ export class ProfileModalController {
this.addRelayButton = document.getElementById("addRelayBtn") || null;
this.restoreRelaysButton =
document.getElementById("restoreRelaysBtn") || null;
this.relayHealthPanel =
document.getElementById("relayHealthPanel") || null;
this.relayHealthList =
document.getElementById("relayHealthList") || null;
this.relayHealthStatus =
document.getElementById("relayHealthStatus") || null;
this.relayHealthRefreshButton =
document.getElementById("relayHealthRefreshBtn") || null;
this.relayHealthTelemetryToggle =
document.getElementById("relayHealthTelemetryOptIn") || null;
this.subscriptionList =
document.getElementById("subscriptionsList") || null;
@@ -4188,6 +4204,18 @@ export class ProfileModalController {
});
}
if (this.relayHealthRefreshButton instanceof HTMLElement) {
this.relayHealthRefreshButton.addEventListener("click", () => {
void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "manual" });
});
}
if (this.relayHealthTelemetryToggle instanceof HTMLInputElement) {
this.relayHealthTelemetryToggle.addEventListener("change", () => {
this.handleRelayHealthTelemetryToggle();
});
}
if (this.addBlockedButton instanceof HTMLElement) {
this.addBlockedButton.addEventListener("click", () => {
void this.handleAddBlockedCreator();
@@ -5828,6 +5856,9 @@ export class ProfileModalController {
if (target === "history") {
void this.populateProfileWatchHistory();
} else if (target === "relays") {
this.populateProfileRelays();
void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "pane-select" });
} else if (target === "messages") {
this.resumeProfileMessages();
void this.populateProfileMessages({ reason: "pane-select" });
@@ -5949,6 +5980,166 @@ export class ProfileModalController {
});
}
updateRelayHealthStatus(message = "") {
if (!this.relayHealthStatus) {
return;
}
const text = typeof message === "string" ? message.trim() : "";
this.relayHealthStatus.textContent = text;
}
renderRelayHealthSnapshot(snapshot = []) {
if (!this.relayHealthList) {
return;
}
this.relayHealthList.innerHTML = "";
if (!snapshot.length) {
const emptyState = document.createElement("li");
emptyState.className =
"card border border-dashed border-surface-strong p-4 text-center text-sm text-muted";
emptyState.textContent = "No relays configured.";
this.relayHealthList.appendChild(emptyState);
return;
}
snapshot.forEach((entry) => {
const item = document.createElement("li");
item.className = "card flex flex-col gap-2 p-4";
const header = document.createElement("div");
header.className = "flex items-center justify-between gap-3";
const urlEl = document.createElement("p");
urlEl.className = "text-sm font-medium text-primary break-all";
urlEl.textContent = entry.url;
const connection = document.createElement("span");
const connected = Boolean(entry.connected);
connection.className = `text-xs font-semibold ${
connected ? "text-status-success" : "text-status-danger"
}`;
connection.textContent = connected ? "Connected" : "Unavailable";
header.appendChild(urlEl);
header.appendChild(connection);
const details = document.createElement("div");
details.className = "grid grid-cols-2 gap-3 text-xs text-muted sm:grid-cols-3";
const latency = document.createElement("div");
latency.className = "flex items-center justify-between gap-2";
const latencyLabel = document.createElement("span");
latencyLabel.textContent = "Latency";
const latencyValue = document.createElement("span");
latencyValue.className = "text-text";
latencyValue.textContent = Number.isFinite(entry.lastLatencyMs)
? `${entry.lastLatencyMs} ms`
: "—";
latency.appendChild(latencyLabel);
latency.appendChild(latencyValue);
const errors = document.createElement("div");
errors.className = "flex items-center justify-between gap-2";
const errorsLabel = document.createElement("span");
errorsLabel.textContent = "Errors";
const errorsValue = document.createElement("span");
errorsValue.className = "text-text";
errorsValue.textContent = Number.isFinite(entry.errorCount)
? `${entry.errorCount}`
: "0";
errors.appendChild(errorsLabel);
errors.appendChild(errorsValue);
const checks = document.createElement("div");
checks.className = "flex items-center justify-between gap-2";
const checksLabel = document.createElement("span");
checksLabel.textContent = "Checked";
const checksValue = document.createElement("span");
checksValue.className = "text-text";
if (Number.isFinite(entry.lastCheckedAt) && entry.lastCheckedAt > 0) {
checksValue.textContent = new Date(entry.lastCheckedAt).toLocaleTimeString();
} else {
checksValue.textContent = "—";
}
checks.appendChild(checksLabel);
checks.appendChild(checksValue);
details.appendChild(latency);
details.appendChild(errors);
details.appendChild(checks);
item.appendChild(header);
item.appendChild(details);
this.relayHealthList.appendChild(item);
});
}
handleRelayHealthTelemetryToggle() {
const service = this.services?.relayHealthService;
if (!service || !(this.relayHealthTelemetryToggle instanceof HTMLInputElement)) {
return;
}
const enabled = service.setTelemetryOptIn(
this.relayHealthTelemetryToggle.checked,
);
this.relayHealthTelemetryToggle.checked = enabled;
this.updateRelayHealthStatus(
enabled ? "Relay health telemetry enabled." : "Relay health telemetry disabled.",
);
}
async refreshRelayHealthPanel({ forceRefresh = false, reason = "" } = {}) {
const service = this.services?.relayHealthService;
if (!service || !this.relayHealthList) {
return [];
}
if (this.relayHealthTelemetryToggle instanceof HTMLInputElement) {
this.relayHealthTelemetryToggle.checked = service.getTelemetryOptIn();
}
const snapshot = service.getSnapshot();
this.renderRelayHealthSnapshot(snapshot);
if (!forceRefresh) {
return snapshot;
}
if (this.relayHealthRefreshPromise) {
return this.relayHealthRefreshPromise;
}
const statusMessage = reason === "manual" ? "Refreshing relay health…" : "Checking relays…";
this.updateRelayHealthStatus(statusMessage);
const refreshPromise = service
.refresh()
.then((latest) => {
this.renderRelayHealthSnapshot(latest);
this.updateRelayHealthStatus("Relay health updated.");
return latest;
})
.catch((error) => {
this.updateRelayHealthStatus("Failed to refresh relay health.");
this.showError("Failed to refresh relay health.");
devLogger.warn("[profileModal] Relay health refresh failed:", error);
return [];
})
.finally(() => {
if (this.relayHealthRefreshPromise === refreshPromise) {
this.relayHealthRefreshPromise = null;
}
});
this.relayHealthRefreshPromise = refreshPromise;
return refreshPromise;
}
async handleRelayOperation(meta = {}, {
successMessage = "Relay preferences updated.",
skipPublishIfUnchanged = true,
@@ -6010,10 +6201,12 @@ export class ProfileModalController {
this.showSuccess(unchangedMessage);
}
this.populateProfileRelays();
void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "relay-update" });
return operationContext;
}
this.populateProfileRelays();
void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "relay-update" });
if (operationContext.ok) {
if (successMessage) {