feat: add per-relay gift wrap statistics and comprehensive debug logging

- Add RelayStats interface tracking success/failed/total counts per relay
- Display relay stats in InboxViewer relay dropdown (success/failed/total)
- Track relay statistics during gift wrap sync and decryption
- Add comprehensive debug logging to trace gift wrap flow:
  - Timeline updates with gift wrap counts
  - Decrypt state transitions (unlocked/pending counts)
  - Individual decrypt attempts with outcomes
  - Persisted encrypted content IDs on load
- Fix relay stats tracking for cached events
- Add 500ms delay after loading cached gift wraps for applesauce symbol attachment
- Improve error messages with String() fallback for non-Error exceptions
- Ensure error isolation: one failed decrypt doesn't affect others
This commit is contained in:
Claude
2026-01-17 21:38:55 +00:00
parent 692b3cbdd7
commit 85174ad913
2 changed files with 179 additions and 17 deletions

View File

@@ -48,6 +48,7 @@ function InboxViewer() {
const decryptStates = use$(giftWrapService.decryptStates$);
const conversations = use$(giftWrapService.conversations$);
const inboxRelays = use$(giftWrapService.inboxRelays$);
const relayStats = use$(giftWrapService.relayStats$);
const [isDecryptingAll, setIsDecryptingAll] = useState(false);
@@ -205,22 +206,53 @@ function InboxViewer() {
<span>{inboxRelays?.length ?? 0}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuLabel className="text-xs">
DM Inbox Relays (kind 10050)
</DropdownMenuLabel>
<DropdownMenuSeparator />
{inboxRelays && inboxRelays.length > 0 ? (
<div className="px-2 py-1 space-y-1 max-h-48 overflow-y-auto">
{inboxRelays.map((relay) => (
<RelayLink
key={relay}
url={relay}
className="text-xs"
iconClassname="size-3"
urlClassname="text-xs"
/>
))}
{inboxRelays.map((relay) => {
const stats = relayStats?.get(relay);
return (
<div
key={relay}
className="flex items-center justify-between gap-2"
>
<RelayLink
url={relay}
className="text-xs flex-1 min-w-0"
iconClassname="size-3"
urlClassname="text-xs"
/>
{stats && (
<div className="flex items-center gap-1.5 text-[10px] font-mono flex-shrink-0">
<span
className="text-green-500"
title="Successfully decrypted"
>
{stats.success}
</span>
<span className="text-muted-foreground">/</span>
<span
className="text-red-500"
title="Failed to decrypt"
>
{stats.failed}
</span>
<span className="text-muted-foreground">/</span>
<span
className="text-muted-foreground"
title="Total gift wraps"
>
{stats.total}
</span>
</div>
)}
</div>
);
})}
</div>
) : (
<DropdownMenuItem disabled className="text-xs">

View File

@@ -23,6 +23,7 @@ import {
getConversationParticipants,
} from "applesauce-common/helpers/messages";
import { persistEncryptedContent } from "applesauce-common/helpers/encrypted-content-cache";
import { getSeenRelays } from "applesauce-core/helpers/relays";
import type { NostrEvent } from "@/types/nostr";
import type { ISigner } from "applesauce-signers";
import eventStore from "./event-store";
@@ -79,6 +80,16 @@ export interface Conversation {
unreadCount?: number;
}
/** Relay statistics for gift wrap tracking */
export interface RelayStats {
/** Number of successfully decrypted gift wraps from this relay */
success: number;
/** Number of failed decryption attempts from this relay */
failed: number;
/** Total gift wraps received from this relay */
total: number;
}
/** Settings for the inbox service */
export interface InboxSettings {
enabled: boolean;
@@ -308,6 +319,12 @@ class GiftWrapService {
/** Inbox relays (kind 10050) */
readonly inboxRelays$ = new BehaviorSubject<string[]>([]);
/** Relay statistics (success/fail counts per relay) */
private relayStats = new Map<string, RelayStats>();
readonly relayStats$ = new BehaviorSubject<Map<string, RelayStats>>(
new Map(),
);
/** Settings */
readonly settings$ = new BehaviorSubject<InboxSettings>(loadSettings());
@@ -397,6 +414,8 @@ class GiftWrapService {
this.decryptStates$.next(new Map());
this.pendingCount$.next(0);
this.conversationIndex.clear();
this.relayStats.clear();
this.relayStats$.next(new Map());
if (!this.settings$.value.enabled) {
dmDebug("GiftWrap", "Inbox sync disabled, skipping initialization");
@@ -444,8 +463,16 @@ class GiftWrapService {
if (!this.userPubkey) return;
try {
dmInfo(
"GiftWrap",
`Loading stored gift wraps for ${this.userPubkey.slice(0, 8)}...`,
);
const storedEvents = await loadStoredGiftWraps(this.userPubkey);
if (storedEvents.length === 0) return;
if (storedEvents.length === 0) {
dmInfo("GiftWrap", "No stored gift wraps found in cache");
return;
}
dmInfo(
"GiftWrap",
@@ -470,12 +497,18 @@ class GiftWrapService {
const elapsed = performance.now() - startTime;
dmInfo(
"GiftWrap",
`Loaded ${storedEvents.length} stored gift wraps in ${elapsed.toFixed(0)}ms`,
`Loaded ${storedEvents.length} stored gift wraps in ${elapsed.toFixed(0)}ms`,
);
this.updateConversations();
// Wait a moment for applesauce encrypted content cache to attach symbols
await new Promise((resolve) => setTimeout(resolve, 500));
dmInfo(
"GiftWrap",
`Persisted encrypted content IDs: ${this.persistedIds.size}`,
);
} catch (err) {
console.warn(`[GiftWrap] Error loading stored gift wraps:`, err);
console.error(`[GiftWrap] Error loading stored gift wraps:`, err);
}
}
@@ -717,7 +750,10 @@ class GiftWrapService {
/** Handle gift wraps update from timeline */
private handleGiftWrapsUpdate(giftWraps: NostrEvent[]) {
dmDebug("GiftWrap", `Timeline update: ${giftWraps.length} gift wraps`);
dmInfo(
"GiftWrap",
`📬 Timeline update: ${giftWraps.length} gift wraps (prev: ${this.giftWraps.length})`,
);
// Find new gift wraps
const newGiftWraps = giftWraps.filter(
@@ -738,27 +774,81 @@ class GiftWrapService {
});
}
}
// Track relay statistics for new gift wraps
for (const gw of newGiftWraps) {
const seenRelays = getSeenRelays(gw);
if (seenRelays && seenRelays.size > 0) {
for (const relayUrl of seenRelays) {
const existing = this.relayStats.get(relayUrl);
const stats: RelayStats = existing ?? {
success: 0,
failed: 0,
total: 0,
};
stats.total++;
this.relayStats.set(relayUrl, stats);
}
}
}
}
this.giftWraps = giftWraps;
this.giftWraps$.next(giftWraps);
// Update decrypt states for new gift wraps
let newUnlocked = 0;
let newPending = 0;
for (const gw of giftWraps) {
if (!this.decryptStates.has(gw.id)) {
const hasSymbol = isGiftWrapUnlocked(gw);
const hasPersisted = this.persistedIds.has(gw.id);
const isUnlocked = hasSymbol || hasPersisted;
if (isUnlocked) {
newUnlocked++;
} else {
newPending++;
}
this.decryptStates.set(gw.id, {
status: isUnlocked ? "success" : "pending",
decryptedAt: isUnlocked ? Date.now() : undefined,
});
// Track relay stats for success
if (isUnlocked) {
const seenRelays = getSeenRelays(gw);
if (seenRelays && seenRelays.size > 0) {
for (const relayUrl of seenRelays) {
const existing = this.relayStats.get(relayUrl);
if (existing) {
existing.success++;
} else {
// Initialize stats for cached events that don't have relay tracking yet
this.relayStats.set(relayUrl, {
success: 1,
failed: 0,
total: 1,
});
}
}
}
}
}
}
dmInfo(
"GiftWrap",
`Decrypt states: ${newUnlocked} unlocked, ${newPending} pending (total: ${this.decryptStates.size})`,
);
this.decryptStates$.next(new Map(this.decryptStates));
this.updatePendingCount();
// Emit relay stats
this.relayStats$.next(new Map(this.relayStats));
// Update conversations
this.updateConversations();
@@ -891,19 +981,45 @@ class GiftWrapService {
// Mark as decrypting
this.decryptStates.set(giftWrapId, { status: "decrypting" });
dmDebug(
"GiftWrap",
`🔓 Attempting to decrypt ${giftWrapId.slice(0, 8)}...`,
);
try {
const rumor = await unlockGiftWrap(gw, this.signer);
if (!rumor) {
throw new Error("unlockGiftWrap returned null/undefined");
}
this.persistedIds.add(giftWrapId);
this.decryptStates.set(giftWrapId, {
status: "success",
decryptedAt: Date.now(),
});
this.decryptEvent$.next({ giftWrapId, status: "success", rumor });
dmDebug("GiftWrap", `✅ Decrypted ${giftWrapId.slice(0, 8)}`);
// Track relay stats for successful decrypt
const seenRelays = getSeenRelays(gw);
if (seenRelays && seenRelays.size > 0) {
for (const relayUrl of seenRelays) {
const existing = this.relayStats.get(relayUrl);
if (existing) {
existing.success++;
this.relayStats.set(relayUrl, existing);
}
}
this.relayStats$.next(new Map(this.relayStats));
}
dmInfo(
"GiftWrap",
`✅ Decrypted ${giftWrapId.slice(0, 8)} (kind: ${rumor.kind})`,
);
return rumor;
} catch (err) {
const error = err instanceof Error ? err.message : "Unknown error";
const error = err instanceof Error ? err.message : String(err);
dmWarn(
"GiftWrap",
`❌ Decrypt failed for ${giftWrapId.slice(0, 8)}: ${error}`,
@@ -912,6 +1028,20 @@ class GiftWrapService {
// FIX: Set error state but DON'T throw - allows other decrypts to continue
this.decryptStates.set(giftWrapId, { status: "error", error });
this.decryptEvent$.next({ giftWrapId, status: "error", error });
// Track relay stats for failed decrypt
const seenRelays = getSeenRelays(gw);
if (seenRelays && seenRelays.size > 0) {
for (const relayUrl of seenRelays) {
const existing = this.relayStats.get(relayUrl);
if (existing) {
existing.failed++;
this.relayStats.set(relayUrl, existing);
}
}
this.relayStats$.next(new Map(this.relayStats));
}
return null;
}
}